mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-01 20:29:17 +01:00
chore(release): automatic release v0.1.0
This commit is contained in:
@@ -37,17 +37,17 @@
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "^7.14.1",
|
||||
"@mantine/core": "^7.14.1",
|
||||
"@mantine/hooks": "^7.14.1",
|
||||
"@mantine/modals": "^7.14.1",
|
||||
"@mantine/tiptap": "^7.14.1",
|
||||
"@million/lint": "1.0.12",
|
||||
"@mantine/colors-generator": "^7.14.3",
|
||||
"@mantine/core": "^7.14.3",
|
||||
"@mantine/hooks": "^7.14.3",
|
||||
"@mantine/modals": "^7.14.3",
|
||||
"@mantine/tiptap": "^7.14.3",
|
||||
"@million/lint": "1.0.13",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.22.0",
|
||||
"@tanstack/react-query": "^5.61.0",
|
||||
"@tanstack/react-query-devtools": "^5.61.0",
|
||||
"@tanstack/react-query-next-experimental": "5.61.0",
|
||||
"@tabler/icons-react": "^3.23.0",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-query-devtools": "^5.62.0",
|
||||
"@tanstack/react-query-next-experimental": "5.62.0",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -80,7 +80,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/chroma-js": "2.4.4",
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
@@ -88,7 +88,7 @@
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"node-loader": "^2.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
5
apps/nextjs/src/app/[locale]/(home-board)/layout.tsx
Normal file
5
apps/nextjs/src/app/[locale]/(home-board)/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import definition from "../boards/(content)/(home)/_definition";
|
||||
|
||||
const { layout } = definition;
|
||||
|
||||
export default layout;
|
||||
7
apps/nextjs/src/app/[locale]/(home-board)/page.tsx
Normal file
7
apps/nextjs/src/app/[locale]/(home-board)/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import definition from "../boards/(content)/(home)/_definition";
|
||||
|
||||
const { generateMetadataAsync: generateMetadata, page } = definition;
|
||||
|
||||
export default page;
|
||||
|
||||
export { generateMetadata };
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
export default function MainLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader />
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Stack>
|
||||
<Title>Home</Title>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -26,6 +28,7 @@ import { useCategoryActions } from "~/components/board/sections/category/categor
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env.mjs";
|
||||
import { useEditMode, useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
@@ -139,6 +142,7 @@ const EditModeMenu = () => {
|
||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||
|
||||
useHotkeys([["mod+e", toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
@@ -146,3 +150,63 @@ const EditModeMenu = () => {
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
||||
const target = (event.target as HTMLElement).closest("a");
|
||||
|
||||
if (!target) return;
|
||||
if (!isDirty) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
openConfirmModal({
|
||||
title: t("board.action.edit.confirmLeave.title"),
|
||||
children: t("board.action.edit.confirmLeave.message"),
|
||||
onConfirm() {
|
||||
router.push(target.href);
|
||||
},
|
||||
confirmProps: {
|
||||
children: t("common.action.discard"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePopState = (event: Event) => {
|
||||
if (isDirty) {
|
||||
window.history.pushState(null, document.title, window.location.href);
|
||||
event.preventDefault();
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!isDirty) return;
|
||||
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
};
|
||||
|
||||
document.querySelectorAll("a").forEach((link) => {
|
||||
link.addEventListener("click", handleClick as never);
|
||||
});
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
document.querySelectorAll("a").forEach((link) => {
|
||||
link.removeEventListener("click", handleClick as never);
|
||||
window.removeEventListener("popstate", handlePopState);
|
||||
});
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDirty]);
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export default async function BoardSettingsPage({ params, searchParams }: Props)
|
||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
|
||||
<DangerZoneSettingsContent hideVisibility={boardSettings.defaultBoardId === board.id} />
|
||||
<DangerZoneSettingsContent hideVisibility={boardSettings.homeBoardId === board.id} />
|
||||
</AccordionItemFor>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconApps, IconPencil } from "@tabler/icons-react";
|
||||
import { IconBox, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -12,6 +12,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { AppDeleteButton } from "./_app-delete-button";
|
||||
|
||||
export default async function AppsPage() {
|
||||
@@ -113,16 +114,14 @@ const AppNoResults = async () => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack align="center" gap="sm">
|
||||
<IconApps size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
{t("app.page.list.noResults.title")}
|
||||
</Text>
|
||||
{session?.user.permissions.includes("app-create") && (
|
||||
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.action")}</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
<NoResults
|
||||
icon={IconBox}
|
||||
title={t("app.page.list.noResults.title")}
|
||||
action={{
|
||||
label: t("app.page.list.noResults.action"),
|
||||
href: "/manage/apps/new",
|
||||
hidden: !session?.user.permissions.includes("app-create"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronDown, IconChevronUp, IconPencil } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -40,6 +40,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
@@ -120,7 +121,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return <div>{t("page.list.empty")}</div>;
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const grouppedIntegrations = integrations.reduce(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { z } from "@homarr/validation";
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
@@ -142,16 +143,14 @@ const SearchEngineNoResults = async () => {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack align="center" gap="sm">
|
||||
<IconSearch size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
{t("search.engine.page.list.noResults.title")}
|
||||
</Text>
|
||||
{session?.user.permissions.includes("search-engine-create") && (
|
||||
<Anchor href="/manage/search-engines/new">{t("search.engine.page.list.noResults.action")}</Anchor>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
<NoResults
|
||||
icon={IconSearch}
|
||||
title={t("search.engine.page.list.noResults.title")}
|
||||
action={{
|
||||
label: t("search.engine.page.list.noResults.action"),
|
||||
href: "/manage/search-engines/new",
|
||||
hidden: !session?.user.permissions.includes("search-engine-create"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,8 +19,8 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
{(form) => (
|
||||
<>
|
||||
<SelectWithCustomItems
|
||||
label={tBoard("defaultBoard.label")}
|
||||
description={tBoard("defaultBoard.description")}
|
||||
label={tBoard("homeBoard.label")}
|
||||
description={tBoard("homeBoard.description")}
|
||||
data={selectableBoards.map((board) => ({
|
||||
value: board.id,
|
||||
label: board.name,
|
||||
@@ -35,7 +35,7 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{...form.getInputProps("defaultBoardId")}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { OpenAPIV3 } from "openapi-types";
|
||||
import type { OpenAPIObject } from "openapi3-ts/oas31";
|
||||
import SwaggerUI from "swagger-ui-react";
|
||||
|
||||
// workaround for CSS that cannot be processed by next.js, https://github.com/swagger-api/swagger-ui/issues/10045
|
||||
@@ -9,7 +9,7 @@ import "../swagger-ui-overrides.css";
|
||||
import "../swagger-ui.css";
|
||||
|
||||
interface SwaggerUIClientProps {
|
||||
document: OpenAPIV3.Document;
|
||||
document: OpenAPIObject;
|
||||
}
|
||||
|
||||
export const SwaggerUIClient = ({ document }: SwaggerUIClientProps) => {
|
||||
|
||||
@@ -31,7 +31,9 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
|
||||
children: t("user.action.delete.confirm", { username: user.name }),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async onConfirm() {
|
||||
await mutateUserDeletionAsync(user.id);
|
||||
await mutateUserDeletionAsync({
|
||||
userId: user.id,
|
||||
});
|
||||
},
|
||||
}),
|
||||
[user, mutateUserDeletionAsync, openConfirmModal, t],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group, Radio, Stack } from "@mantine/core";
|
||||
import type { DayOfWeek } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import localeData from "dayjs/plugin/localeData";
|
||||
|
||||
@@ -43,7 +44,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
|
||||
});
|
||||
const form = useZodForm(validation.user.firstDayOfWeek, {
|
||||
initialValues: {
|
||||
firstDayOfWeek: user.firstDayOfWeek,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
|
||||
import { IconUserCheck } from "@tabler/icons-react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Stepper,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form";
|
||||
|
||||
import { GroupSelectModal } from "~/components/access/group-select-modal";
|
||||
import { StepperNavigationComponent } from "./stepper-navigation";
|
||||
|
||||
export const UserCreateStepperComponent = () => {
|
||||
interface GroupWithPermissions {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: GroupPermissionKey[];
|
||||
}
|
||||
|
||||
interface UserCreateStepperComponentProps {
|
||||
initialGroups: GroupWithPermissions[];
|
||||
}
|
||||
|
||||
export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperComponentProps) => {
|
||||
const t = useScopedI18n("management.page.user.create");
|
||||
const tUserField = useScopedI18n("user.field");
|
||||
|
||||
@@ -73,7 +101,18 @@ export const UserCreateStepperComponent = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
|
||||
const groupsForm = useZodForm(
|
||||
z.object({
|
||||
groups: z.array(z.string()),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
groups: initialGroups.map((group) => group.id),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const allForms = useMemo(() => [generalForm, securityForm, groupsForm], [generalForm, securityForm, groupsForm]);
|
||||
|
||||
const activeForm = allForms[active];
|
||||
const isCurrentFormValid = activeForm ? activeForm.isValid : () => true;
|
||||
@@ -86,10 +125,11 @@ export const UserCreateStepperComponent = () => {
|
||||
email: generalForm.values.email,
|
||||
password: securityForm.values.password,
|
||||
confirmPassword: securityForm.values.confirmPassword,
|
||||
groupIds: groupsForm.values.groups,
|
||||
});
|
||||
}
|
||||
nextStep();
|
||||
}, [active, generalForm, mutateAsync, securityForm, nextStep]);
|
||||
}, [active, generalForm, securityForm, groupsForm, mutateAsync, nextStep]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setActive(0);
|
||||
@@ -144,13 +184,18 @@ export const UserCreateStepperComponent = () => {
|
||||
</Card>
|
||||
</form>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step
|
||||
label={t("step.permissions.label")}
|
||||
description={t("step.permissions.description")}
|
||||
allowStepSelect={false}
|
||||
allowStepClick={false}
|
||||
>
|
||||
3
|
||||
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl">
|
||||
<GroupsForm
|
||||
initialGroups={initialGroups}
|
||||
addGroup={(groupId) =>
|
||||
groupsForm.setValues((value) => ({ groups: value.groups?.concat(groupId) ?? [groupId] }))
|
||||
}
|
||||
removeGroup={(groupId) => {
|
||||
groupsForm.setValues((value) => ({ groups: value.groups?.filter((group) => group !== groupId) ?? [] }));
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
|
||||
<Card p="xl">
|
||||
@@ -183,3 +228,111 @@ export const UserCreateStepperComponent = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface GroupsFormProps {
|
||||
addGroup: (groupId: string) => void;
|
||||
removeGroup: (groupId: string) => void;
|
||||
initialGroups: GroupWithPermissions[];
|
||||
}
|
||||
|
||||
const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) => {
|
||||
const t = useI18n();
|
||||
const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups);
|
||||
const { openModal } = useModalAction(GroupSelectModal);
|
||||
|
||||
const handleAddClick = () => {
|
||||
openModal({
|
||||
presentGroupIds: groups.map((group) => group.id),
|
||||
withPermissions: true,
|
||||
onSelect({ id, name, permissions }) {
|
||||
if (!permissions) return;
|
||||
|
||||
startTransition(() => {
|
||||
addGroup(id);
|
||||
append({ id, name, permissions });
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleGroupRemove = (id: string) => {
|
||||
filter((group) => group.id !== id);
|
||||
removeGroup(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{t("management.page.user.create.step.groups.title")}</Text>
|
||||
<Text size="sm" c="gray.6">
|
||||
{t("management.page.user.create.step.groups.description", { everyoneGroup })}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={16} stroke={1.5} />}
|
||||
onClick={handleAddClick}
|
||||
>
|
||||
{t("common.action.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("group.field.name")}</Table.Th>
|
||||
<Table.Th>{t("permission.title")}</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{groups.map((group) => (
|
||||
<Table.Tr key={group.id}>
|
||||
<Table.Td>{group.name}</Table.Td>
|
||||
<Table.Td w="100%">
|
||||
<Group gap="xs">
|
||||
{Object.entries(groupPermissions)
|
||||
.flatMap(([key, values]) =>
|
||||
Array.isArray(values)
|
||||
? values.map((value) => ({ key, value: value as string }))
|
||||
: [{ key, value: key }],
|
||||
)
|
||||
.filter(({ key, value }) =>
|
||||
group.permissions.some(
|
||||
(permission) => permission === (key === value ? key : `${key}-${value}`),
|
||||
),
|
||||
)
|
||||
.map(({ key, value }) => (
|
||||
<PermissionBadge key={`${key}-${value}`} category={key} value={value} />
|
||||
))}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{group.name !== everyoneGroup && (
|
||||
<Button variant="subtle" onClick={() => handleGroupRemove(group.id)}>
|
||||
{t("common.action.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const PermissionBadge = ({ category, value }: { category: string; value: string }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Tooltip label={t(`group.permission.${category}.item.${value}.description` as never)}>
|
||||
<Badge color={category === "admin" ? "red" : "blue"} size="sm" variant="dot">
|
||||
{t(`group.permission.${category}.item.${value}.label` as never)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { db, inArray } from "@homarr/db";
|
||||
import { groups } from "@homarr/db/schema/sqlite";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
@@ -33,10 +36,22 @@ export default async function CreateUserPage() {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const initialGroups = await db.query.groups.findMany({
|
||||
where: inArray(groups.name, [everyoneGroup]),
|
||||
with: {
|
||||
permissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<UserCreateStepperComponent />
|
||||
<UserCreateStepperComponent
|
||||
initialGroups={initialGroups.map((group) => ({
|
||||
...group,
|
||||
permissions: group.permissions.map(({ permission }) => permission),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
||||
import { Button, useMatches } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -46,7 +47,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
|
||||
userId: id,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
async onSuccess() {
|
||||
setInnerOwnerId(id);
|
||||
showSuccessNotification({
|
||||
title: tRoot("common.notification.transfer.success"),
|
||||
@@ -55,6 +56,7 @@ export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) =
|
||||
user: name,
|
||||
}),
|
||||
});
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${group.id}`);
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
|
||||
@@ -32,6 +32,7 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps)
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
|
||||
},
|
||||
presentUserIds,
|
||||
excludeExternalProviders: true,
|
||||
},
|
||||
{
|
||||
title: tMembersAdd("label"),
|
||||
|
||||
@@ -11,13 +11,9 @@ import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
WidgetEditModal,
|
||||
widgetImports,
|
||||
} from "@homarr/widgets";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Dimensions } from "./_dimension-modal";
|
||||
import { PreviewDimensionsModal } from "./_dimension-modal";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { headers } from "next/headers";
|
||||
import { userAgent } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createOpenApiFetchHandler } from "trpc-swagger/build/index.mjs";
|
||||
import { createOpenApiFetchHandler } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||
import { hashPasswordAsync } from "@homarr/auth";
|
||||
@@ -17,6 +17,11 @@ const handlerAsync = async (req: NextRequest) => {
|
||||
const { ua } = userAgent(req);
|
||||
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
|
||||
|
||||
// Fallback to JSON if no content type is set
|
||||
if (!req.headers.has("Content-Type")) {
|
||||
req.headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return createOpenApiFetchHandler({
|
||||
req,
|
||||
endpoint: "/",
|
||||
@@ -82,4 +87,10 @@ const getSessionOrDefaultFromHeadersAsync = async (
|
||||
return await createSessionAsync(db, apiKeyFromDb.user);
|
||||
};
|
||||
|
||||
export { handlerAsync as GET, handlerAsync as POST };
|
||||
export {
|
||||
handlerAsync as GET,
|
||||
handlerAsync as POST,
|
||||
handlerAsync as PUT,
|
||||
handlerAsync as DELETE,
|
||||
handlerAsync as PATCH,
|
||||
};
|
||||
|
||||
@@ -2,13 +2,15 @@ import { useState } from "react";
|
||||
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface InnerProps {
|
||||
withPermissions?: boolean;
|
||||
presentGroupIds: string[];
|
||||
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
|
||||
onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
@@ -18,7 +20,9 @@ interface GroupSelectFormType {
|
||||
|
||||
export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: groups, isPending } = clientApi.group.selectable.useQuery();
|
||||
const { data: groups, isPending } = clientApi.group.selectable.useQuery({
|
||||
withPermissions: innerProps.withPermissions,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<GroupSelectFormType>();
|
||||
const handleSubmitAsync = async (values: GroupSelectFormType) => {
|
||||
@@ -28,6 +32,7 @@ export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }
|
||||
await innerProps.onSelect({
|
||||
id: currentGroup.id,
|
||||
name: currentGroup.name,
|
||||
permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined,
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
excludeExternalProviders?: boolean;
|
||||
onSelect: (props: { id: string; name: string; image: string }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
@@ -22,7 +23,9 @@ interface UserSelectFormType {
|
||||
|
||||
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery({
|
||||
excludeExternalProviders: innerProps.excludeExternalProviders,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<UserSelectFormType>();
|
||||
const handleSubmitAsync = async (values: UserSelectFormType) => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { WidgetEditModal, widgetImports } from "@homarr/widgets";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveCategoryInput {
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export const moveCategoryCallback =
|
||||
(input: MoveCategoryInput) =>
|
||||
(previous: RouterOutputs["board"]["getHomeBoard"]): RouterOutputs["board"]["getHomeBoard"] => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === input.id,
|
||||
);
|
||||
if (!currentCategory) {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === 1 && input.direction === "up") {
|
||||
return previous;
|
||||
}
|
||||
if (currentCategory.yOffset === previous.sections.length - 2 && input.direction === "down") {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category" && section.kind !== "empty") {
|
||||
return section;
|
||||
}
|
||||
const offset = input.direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (section.yOffset === currentCategory.yOffset || section.yOffset === currentCategory.yOffset + 1) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
section.yOffset === currentCategory.yOffset + offset ||
|
||||
section.yOffset === currentCategory.yOffset + offset + 1
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset - offset,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { moveCategoryCallback } from "../move-category";
|
||||
|
||||
describe("Move Category", () => {
|
||||
test.each([
|
||||
[3, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[5, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category up", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test.each([
|
||||
[1, [0, 3, 4, 1, 2, 5, 6]],
|
||||
[3, [0, 1, 2, 5, 6, 3, 4]],
|
||||
])("should move category down", (moveId, expectedOrder) => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: moveId.toString(),
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual(expectedOrder);
|
||||
});
|
||||
test("should not move category up if it is at the top", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "1",
|
||||
direction: "up" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
test("should not move category down if it is at the bottom", () => {
|
||||
const sections = createSections(3);
|
||||
|
||||
const input = {
|
||||
id: "5",
|
||||
direction: "down" as const,
|
||||
};
|
||||
|
||||
const result = moveCategoryCallback(input)({ sections } as never);
|
||||
|
||||
expect(sortSections(result.sections).map((section) => parseInt(section.id, 10))).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
const createSections = (categoryCount: number) => {
|
||||
return Array.from({ length: categoryCount * 2 + 1 }, (_, index) => ({
|
||||
id: index.toString(),
|
||||
kind: index % 2 === 1 ? ("category" as const) : ("empty" as const),
|
||||
name: `Category ${index}`,
|
||||
yOffset: index,
|
||||
xOffset: 0,
|
||||
items: [],
|
||||
})) satisfies Section[];
|
||||
};
|
||||
|
||||
const sortSections = (sections: Section[]) => {
|
||||
return sections.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { CategorySection, EmptySection, Section } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import type { MoveCategoryInput } from "./actions/move-category";
|
||||
import { moveCategoryCallback } from "./actions/move-category";
|
||||
|
||||
interface AddCategory {
|
||||
name: string;
|
||||
@@ -15,11 +17,6 @@ interface RenameCategory {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MoveCategory {
|
||||
id: string;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
interface RemoveCategory {
|
||||
id: string;
|
||||
}
|
||||
@@ -128,52 +125,8 @@ export const useCategoryActions = () => {
|
||||
);
|
||||
|
||||
const moveCategory = useCallback(
|
||||
({ id, direction }: MoveCategory) => {
|
||||
updateBoard((previous) => {
|
||||
const currentCategory = previous.sections.find(
|
||||
(section): section is CategorySection => section.kind === "category" && section.id === id,
|
||||
);
|
||||
if (!currentCategory) return previous;
|
||||
if (currentCategory.yOffset === 1 && direction === "up") return previous;
|
||||
if (currentCategory.yOffset === previous.sections.length - 2 && direction === "down") return previous;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
if (section.kind !== "category" && section.kind !== "empty") return section;
|
||||
const offset = direction === "up" ? -2 : 2;
|
||||
// Move category and empty section
|
||||
if (section.yOffset === currentCategory.yOffset || section.yOffset - 1 === currentCategory.yOffset) {
|
||||
return {
|
||||
...section,
|
||||
yOffset: section.yOffset + offset,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "up" &&
|
||||
(section.yOffset === currentCategory.yOffset - 2 || section.yOffset === currentCategory.yOffset - 1)
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.yOffset + 2,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
direction === "down" &&
|
||||
(section.yOffset === currentCategory.yOffset + 2 || section.yOffset === currentCategory.yOffset + 3)
|
||||
) {
|
||||
return {
|
||||
...section,
|
||||
position: section.yOffset - 2,
|
||||
};
|
||||
}
|
||||
|
||||
return section;
|
||||
}),
|
||||
};
|
||||
});
|
||||
(input: MoveCategoryInput) => {
|
||||
updateBoard(moveCategoryCallback(input));
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UnstyledButton,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -34,16 +35,19 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const [data] = clientApi.icon.findIcons.useSuspenseQuery({
|
||||
searchText: search,
|
||||
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||
|
||||
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||
const { data } = clientApi.icon.findIcons.useQuery({
|
||||
searchText: debouncedSearch,
|
||||
});
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const totalOptions = data.icons.reduce((acc, group) => acc + group.icons.length, 0);
|
||||
const groups = data.icons.map((group) => {
|
||||
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
|
||||
const groups = data?.icons.map((group) => {
|
||||
const options = group.icons.map((item) => (
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
@@ -92,8 +96,12 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
leftSection={previewUrl ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null}
|
||||
leftSection={
|
||||
previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
|
||||
) : null
|
||||
}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
@@ -118,7 +126,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
|
||||
withAsterisk
|
||||
error={error}
|
||||
label={tCommon("iconPicker.label")}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data.countIcons })}
|
||||
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
|
||||
26
apps/nextjs/src/components/no-results.tsx
Normal file
26
apps/nextjs/src/components/no-results.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Anchor, Card, Stack, Text } from "@mantine/core";
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
|
||||
interface NoResultsProps {
|
||||
icon: TablerIcon;
|
||||
title: string;
|
||||
action?: {
|
||||
label: string;
|
||||
href: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
|
||||
return (
|
||||
<Card withBorder bg="transparent">
|
||||
<Stack align="center" gap="sm">
|
||||
<Icon size="2rem" />
|
||||
<Text fw={500} size="lg">
|
||||
{title}
|
||||
</Text>
|
||||
{!action?.hidden && <Anchor href={action?.href}>{action?.label}</Anchor>}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -61,7 +61,8 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
||||
}, [logoutUrl, openModal, router]);
|
||||
|
||||
return (
|
||||
<Menu width={300} withArrow withinPortal>
|
||||
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
|
||||
<Menu width={300} withArrow withinPortal keepMounted>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={toggleColorScheme} leftSection={<ColorSchemeIcon size="1rem" />}>
|
||||
{colorSchemeText}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { createHeadersCallbackForSource } from "@homarr/api/client";
|
||||
import { createI18nMiddleware } from "@homarr/translation/middleware";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
@@ -25,11 +26,7 @@ export const serverFetchApi = createTRPCClient<AppRouter>({
|
||||
httpLink({
|
||||
url: `http://${process.env.HOSTNAME ?? "localhost"}:3000/api/trpc`,
|
||||
transformer: SuperJSON,
|
||||
headers() {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "server-fetch");
|
||||
return headers;
|
||||
},
|
||||
headers: createHeadersCallbackForSource("server-fetch"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=tasks.cjs",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --outfile=tasks.cjs",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -38,17 +38,17 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.5",
|
||||
"superjson": "2.2.1",
|
||||
"undici": "6.21.0"
|
||||
"undici": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^22.9.1",
|
||||
"@types/node": "^22.10.1",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.4.1",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LoggingAgent } from "~/undici-log-agent-override";
|
||||
vi.mock("undici", () => {
|
||||
return {
|
||||
Agent: class Agent {
|
||||
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandlers): boolean {
|
||||
dispatch(_options: Dispatcher.DispatchOptions, _handler: Dispatcher.DispatchHandler): boolean {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ export class LoggingAgent extends Agent {
|
||||
super(...props);
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean {
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const url = new URL(`${options.origin as string}${options.path}`);
|
||||
|
||||
// The below code should prevent sensitive data from being logged as
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"main": "./src/main.ts",
|
||||
"types": "./src/main.ts",
|
||||
"scripts": {
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:cpu-features --loader:.html=text --loader:.node=text",
|
||||
"build": "esbuild src/main.ts --bundle --platform=node --outfile=wssServer.cjs --external:bcrypt --external:@opentelemetry/api --external:cpu-features --loader:.html=text --loader:.scss=text --loader:.node=text",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"dev": "pnpm with-env tsx ./src/main.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
24
package.json
24
package.json
@@ -19,6 +19,7 @@
|
||||
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
|
||||
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
"package:new": "turbo gen init",
|
||||
"test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development vitest e2e",
|
||||
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
|
||||
@@ -28,26 +29,21 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@turbo/gen": "^2.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"@vitest/ui": "^2.1.5",
|
||||
"@turbo/gen": "^2.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.6",
|
||||
"@vitest/ui": "^2.1.6",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.4.1",
|
||||
"testcontainers": "^10.15.0",
|
||||
"turbo": "^2.3.1",
|
||||
"typescript": "^5.6.3",
|
||||
"turbo": "^2.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "^5.1.3",
|
||||
"vitest": "^2.1.5"
|
||||
"vitest": "^2.1.6"
|
||||
},
|
||||
"packageManager": "pnpm@9.14.2",
|
||||
"packageManager": "pnpm@9.14.4",
|
||||
"engines": {
|
||||
"node": ">=22.11.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/request-handler": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@trpc/client": "next",
|
||||
@@ -42,7 +43,7 @@
|
||||
"next": "^14.2.18",
|
||||
"react": "^18.3.1",
|
||||
"superjson": "2.2.1",
|
||||
"trpc-swagger": "^1.2.6"
|
||||
"trpc-to-openapi": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -50,7 +51,7 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.join(",")}])`,
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,83 +150,6 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integrations and their items in the context that are of the specified kinds and have the specified item
|
||||
* It also ensures that the user has permission to perform the specified action on the integrations
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration for the item was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
|
||||
*/
|
||||
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return publicProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
const dbIntegrations = await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: {
|
||||
with: {
|
||||
section: {
|
||||
columns: {
|
||||
boardId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
|
||||
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
|
||||
integration.items.some((item) => item.itemId === input.itemId),
|
||||
);
|
||||
|
||||
if (dbIntegrationWithItem.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integrations for item were not found",
|
||||
});
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrationWithItem.map(
|
||||
({ secrets, kind, groupPermissions: _ignore1, userPermissions: _ignore2, ...rest }) => ({
|
||||
...rest,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
|
||||
* @param action action to perform
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateOpenApiDocument } from "trpc-swagger";
|
||||
import { generateOpenApiDocument } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter } from "./root";
|
||||
|
||||
|
||||
@@ -2,25 +2,17 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { asc, createId, eq, inArray, like } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema/sqlite";
|
||||
import { selectAppSchema } from "@homarr/db/validationSchemas";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { canUserSeeAppAsync } from "./app/app-access-control";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
all: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
description: z.string().nullable(),
|
||||
iconUrl: z.string(),
|
||||
href: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
.query(({ ctx }) => {
|
||||
return ctx.db.query.apps.findMany({
|
||||
@@ -29,17 +21,7 @@ export const appRouter = createTRPCRouter({
|
||||
}),
|
||||
search: protectedProcedure
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
description: z.string().nullable(),
|
||||
iconUrl: z.string(),
|
||||
href: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.output(z.array(selectAppSchema))
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
|
||||
.query(({ ctx, input }) => {
|
||||
return ctx.db.query.apps.findMany({
|
||||
@@ -50,17 +32,7 @@ export const appRouter = createTRPCRouter({
|
||||
}),
|
||||
selectable: protectedProcedure
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
iconUrl: z.string(),
|
||||
description: z.string().nullable(),
|
||||
href: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.output(z.array(selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, description: true })))
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
@@ -83,15 +55,7 @@ export const appRouter = createTRPCRouter({
|
||||
}),
|
||||
byId: publicProcedure
|
||||
.input(validation.common.byId)
|
||||
.output(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
description: z.string().nullable(),
|
||||
iconUrl: z.string(),
|
||||
href: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.output(selectAppSchema)
|
||||
.meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
||||
.query(async ({ ctx, input }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
@@ -136,7 +100,9 @@ export const appRouter = createTRPCRouter({
|
||||
}),
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("app-modify-all")
|
||||
.input(validation.app.edit)
|
||||
.input(convertIntersectionToZodObject(validation.app.edit))
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
where: eq(apps.id, input.id),
|
||||
|
||||
@@ -229,10 +229,10 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
|
||||
if (input.visibility !== "public" && boardSettings.defaultBoardId === input.id) {
|
||||
if (input.visibility !== "public" && boardSettings.homeBoardId === input.id) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot make default board private",
|
||||
message: "Cannot make home board private",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,13 +259,13 @@ export const boardRouter = createTRPCRouter({
|
||||
})
|
||||
: null;
|
||||
|
||||
// 1. user home board, 2. default board, 3. not found
|
||||
// 1. user home board, 2. home board, 3. not found
|
||||
let boardWhere: SQL<unknown> | null = null;
|
||||
if (user?.homeBoardId) {
|
||||
boardWhere = eq(boards.id, user.homeBoardId);
|
||||
} else {
|
||||
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
|
||||
boardWhere = boardSettings.defaultBoardId ? eq(boards.id, boardSettings.defaultBoardId) : null;
|
||||
boardWhere = boardSettings.homeBoardId ? eq(boards.id, boardSettings.homeBoardId) : null;
|
||||
}
|
||||
|
||||
if (!boardWhere) {
|
||||
|
||||
@@ -100,14 +100,33 @@ export const groupRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
// Is protected because also used in board access / integration access forms
|
||||
selectable: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.groups.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
selectable: protectedProcedure
|
||||
.input(z.object({ withPermissions: z.boolean().default(false) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const withPermissions = input?.withPermissions && ctx.session.user.permissions.includes("admin");
|
||||
|
||||
if (!withPermissions) {
|
||||
return await ctx.db.query.groups.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const groups = await ctx.db.query.groups.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
with: { permissions: { columns: { permission: true } } },
|
||||
});
|
||||
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
permissions: group.permissions.map((permission) => permission.permission),
|
||||
}));
|
||||
}),
|
||||
search: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
|
||||
@@ -3,36 +3,51 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { asc, createId, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema/sqlite";
|
||||
import { selectInviteSchema } from "@homarr/db/validationSchemas";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
const dbInvites = await ctx.db.query.invites.findMany({
|
||||
orderBy: asc(invites.expirationDate),
|
||||
columns: {
|
||||
token: false,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
getAll: protectedProcedure
|
||||
.output(
|
||||
z.array(
|
||||
selectInviteSchema
|
||||
.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
expirationDate: true,
|
||||
})
|
||||
.extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
|
||||
),
|
||||
)
|
||||
.input(z.undefined())
|
||||
.meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
return await ctx.db.query.invites.findMany({
|
||||
orderBy: asc(invites.expirationDate),
|
||||
columns: {
|
||||
token: false,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return dbInvites;
|
||||
}),
|
||||
});
|
||||
}),
|
||||
createInvite: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
expirationDate: z.date(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ id: z.string(), token: z.string() }))
|
||||
.meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
const id = createId();
|
||||
@@ -56,6 +71,8 @@ export const inviteRouter = createTRPCRouter({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.output(z.undefined())
|
||||
.meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
|
||||
@@ -507,7 +507,7 @@ describe("getHomeBoard should return home board", () => {
|
||||
const fullBoardProps = await createFullBoardAsync(db, "home");
|
||||
await db.insert(serverSettings).values({
|
||||
settingKey: "board",
|
||||
value: SuperJSON.stringify({ defaultBoardId: fullBoardProps.boardId }),
|
||||
value: SuperJSON.stringify({ homeBoardId: fullBoardProps.boardId }),
|
||||
});
|
||||
|
||||
// Act
|
||||
|
||||
@@ -316,7 +316,7 @@ describe("delete should delete user", () => {
|
||||
|
||||
await db.insert(schema.users).values(initialUsers);
|
||||
|
||||
await caller.delete(defaultOwnerId);
|
||||
await caller.delete({ userId: defaultOwnerId });
|
||||
|
||||
const usersInDb = await db.select().from(schema.users);
|
||||
expect(usersInDb).toHaveLength(2);
|
||||
|
||||
@@ -4,10 +4,12 @@ import { createSaltAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, createId, eq, like, schema } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema/sqlite";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
|
||||
@@ -44,31 +46,34 @@ export const userRouter = createTRPCRouter({
|
||||
userId,
|
||||
});
|
||||
}),
|
||||
register: publicProcedure.input(validation.user.registrationApi).mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
expirationDate: true,
|
||||
},
|
||||
where: inviteWhere,
|
||||
});
|
||||
|
||||
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid invite",
|
||||
register: publicProcedure
|
||||
.input(validation.user.registrationApi)
|
||||
.output(z.void())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfCredentialsDisabled();
|
||||
const inviteWhere = and(eq(invites.id, input.inviteId), eq(invites.token, input.token));
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
expirationDate: true,
|
||||
},
|
||||
where: inviteWhere,
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid invite",
|
||||
});
|
||||
}
|
||||
|
||||
await createUserAsync(ctx.db, input);
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||
|
||||
// Delete invite as it's used
|
||||
await ctx.db.delete(invites).where(inviteWhere);
|
||||
}),
|
||||
await createUserAsync(ctx.db, input);
|
||||
|
||||
// Delete invite as it's used
|
||||
await ctx.db.delete(invites).where(inviteWhere);
|
||||
}),
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
|
||||
@@ -78,9 +83,15 @@ export const userRouter = createTRPCRouter({
|
||||
throwIfCredentialsDisabled();
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.username);
|
||||
|
||||
await createUserAsync(ctx.db, input);
|
||||
const userId = await createUserAsync(ctx.db, input);
|
||||
|
||||
if (input.groupIds.length >= 1) {
|
||||
await ctx.db.insert(groupMembers).values(input.groupIds.map((groupId) => ({ groupId, userId })));
|
||||
}
|
||||
}),
|
||||
setProfileImage: protectedProcedure
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
@@ -134,17 +145,7 @@ export const userRouter = createTRPCRouter({
|
||||
getAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
emailVerified: z.date().nullable(),
|
||||
image: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
|
||||
.meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
|
||||
.query(({ ctx }) => {
|
||||
return ctx.db.query.users.findMany({
|
||||
@@ -158,15 +159,20 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}),
|
||||
// Is protected because also used in board access / integration access forms
|
||||
selectable: protectedProcedure.query(({ ctx }) => {
|
||||
return ctx.db.query.users.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
selectable: protectedProcedure
|
||||
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
|
||||
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
|
||||
.meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
|
||||
.query(({ ctx, input }) => {
|
||||
return ctx.db.query.users.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
|
||||
});
|
||||
}),
|
||||
search: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
@@ -175,6 +181,8 @@ export const userRouter = createTRPCRouter({
|
||||
limit: z.number().min(1).max(100).default(10),
|
||||
}),
|
||||
)
|
||||
.output(z.array(selectUserSchema.pick({ id: true, name: true, image: true })))
|
||||
.meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
|
||||
.query(async ({ input, ctx }) => {
|
||||
const dbUsers = await ctx.db.query.users.findMany({
|
||||
columns: {
|
||||
@@ -191,16 +199,10 @@ export const userRouter = createTRPCRouter({
|
||||
image: user.image,
|
||||
}));
|
||||
}),
|
||||
getById: protectedProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
|
||||
// Only admins can view other users details
|
||||
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to view other users details",
|
||||
});
|
||||
}
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.output(
|
||||
selectUserSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
@@ -210,134 +212,170 @@ export const userRouter = createTRPCRouter({
|
||||
homeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}),
|
||||
editProfile: protectedProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
|
||||
// Only admins can view other users details
|
||||
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to edit other users details",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true, provider: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (user.provider !== "credentials") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Username and email can not be changed for users with external providers",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
|
||||
|
||||
const emailDirty = input.email && user.email !== input.email;
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: input.name,
|
||||
email: emailDirty === true ? input.email : undefined,
|
||||
emailVerified: emailDirty === true ? null : undefined,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
delete: protectedProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||
// Only admins and user itself can delete a user
|
||||
if (ctx.session.user.id !== input && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to delete other users",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(users).where(eq(users.id, input));
|
||||
}),
|
||||
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
provider: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (dbUser.provider !== "credentials") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Password can not be changed for users with external providers",
|
||||
});
|
||||
}
|
||||
|
||||
// Admins can change the password of other users without providing the previous password
|
||||
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||
|
||||
logger.info(
|
||||
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
|
||||
);
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
|
||||
const isValid = previousPasswordHash === dbUser.password;
|
||||
|
||||
if (!isValid) {
|
||||
}),
|
||||
)
|
||||
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||
.query(async ({ input, ctx }) => {
|
||||
// Only admins can view other users details
|
||||
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid password",
|
||||
message: "You are not allowed to view other users details",
|
||||
});
|
||||
}
|
||||
}
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
image: true,
|
||||
provider: true,
|
||||
homeBoardId: true,
|
||||
firstDayOfWeek: true,
|
||||
pingIconsEnabled: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}),
|
||||
editProfile: protectedProcedure
|
||||
.input(validation.user.editProfile)
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Only admins can view other users details
|
||||
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to edit other users details",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true, provider: true },
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (user.provider !== "credentials") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Username and email can not be changed for users with external providers",
|
||||
});
|
||||
}
|
||||
|
||||
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
|
||||
|
||||
const emailDirty = input.email && user.email !== input.email;
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
name: input.name,
|
||||
email: emailDirty === true ? input.email : undefined,
|
||||
emailVerified: emailDirty === true ? null : undefined,
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Only admins and user itself can delete a user
|
||||
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to delete other users",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(users).where(eq(users.id, input.userId));
|
||||
}),
|
||||
changePassword: protectedProcedure
|
||||
.input(validation.user.changePasswordApi)
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const dbUser = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
password: true,
|
||||
salt: true,
|
||||
provider: true,
|
||||
},
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!dbUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (dbUser.provider !== "credentials") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Password can not be changed for users with external providers",
|
||||
});
|
||||
}
|
||||
|
||||
// Admins can change the password of other users without providing the previous password
|
||||
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||
|
||||
logger.info(
|
||||
`User ${user.id} is changing password for user ${input.userId}, previous password is required: ${isPreviousPasswordRequired}`,
|
||||
);
|
||||
|
||||
if (isPreviousPasswordRequired) {
|
||||
const previousPasswordHash = await hashPasswordAsync(input.previousPassword, dbUser.salt ?? "");
|
||||
const isValid = previousPasswordHash === dbUser.password;
|
||||
|
||||
if (!isValid) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invalid password",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
changeHomeBoardId: protectedProcedure
|
||||
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() })))
|
||||
.input(convertIntersectionToZodObject(validation.user.changeHomeBoard.and(z.object({ userId: z.string() }))))
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users passwords
|
||||
@@ -369,14 +407,18 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
changeColorScheme: protectedProcedure.input(validation.user.changeColorScheme).mutation(async ({ input, ctx }) => {
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
colorScheme: input.colorScheme,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
changeColorScheme: protectedProcedure
|
||||
.input(validation.user.changeColorScheme)
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
colorScheme: input.colorScheme,
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.session?.user) {
|
||||
return false;
|
||||
@@ -410,7 +452,7 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
|
||||
getFirstDayOfWeekForUserOrDefault: publicProcedure.input(z.undefined()).query(async ({ ctx }) => {
|
||||
if (!ctx.session?.user) {
|
||||
return 1 as const;
|
||||
}
|
||||
@@ -426,7 +468,9 @@ export const userRouter = createTRPCRouter({
|
||||
return user?.firstDayOfWeek ?? (1 as const);
|
||||
}),
|
||||
changeFirstDayOfWeek: protectedProcedure
|
||||
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
|
||||
.input(convertIntersectionToZodObject(validation.user.firstDayOfWeek.and(validation.common.byId)))
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Only admins can change other users first day of week
|
||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||
@@ -459,7 +503,7 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {
|
||||
const createUserAsync = async (db: Database, input: Omit<z.infer<typeof validation.user.baseCreate>, "groupIds">) => {
|
||||
const salt = await createSaltAsync();
|
||||
const hashedPassword = await hashPasswordAsync(input.password, salt);
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const calendarRouter = createTRPCRouter({
|
||||
findAllEvents: publicProcedure
|
||||
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx }) => {
|
||||
const result = await Promise.all(
|
||||
ctx.integrations.flatMap(async (integration) => {
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||
return await cache.getAsync();
|
||||
.input(z.object({ year: z.number(), month: z.number(), releaseType: z.array(z.enum(radarrReleaseTypes)) }))
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = calendarMonthRequestHandler.handler(integration, input);
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return data;
|
||||
}),
|
||||
);
|
||||
return result.filter((item) => item !== null).flatMap((item) => item.data);
|
||||
return results.flat();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2,31 +2,33 @@ import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration } from "@homarr/db/schema/sqlite";
|
||||
import type { IntegrationKindByCategory, WidgetKind } from "@homarr/definitions";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { controlsInputSchema } from "@homarr/integrations/types";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { z } from "@homarr/validation";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const dnsHoleRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.query(async ({ input: { widgetKind }, ctx }) => {
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
|
||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind, integration.id);
|
||||
const { data: summary, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration,
|
||||
timestamp,
|
||||
summary,
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -34,22 +36,19 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
subscribeToSummary: publicProcedure
|
||||
.input(z.object({ widgetKind: z.enum(["dnsHoleSummary", "dnsHoleControls"]) }))
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.subscription(({ input: { widgetKind }, ctx }) => {
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"dnsHole"> }>;
|
||||
timestamp: Date;
|
||||
summary: DnsHoleSummary;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(widgetKind as WidgetKind, integration.id);
|
||||
const unsubscribe = channel.subscribe((summary) => {
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||
emit.next({
|
||||
integration,
|
||||
timestamp: new Date(),
|
||||
summary,
|
||||
});
|
||||
});
|
||||
@@ -68,6 +67,12 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = integrationCreator(integration);
|
||||
await client.enableAsync();
|
||||
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||
// We need to wait for the integration to be enabled before invalidating the cache
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
|
||||
});
|
||||
}),
|
||||
|
||||
disable: publicProcedure
|
||||
@@ -76,5 +81,11 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = integrationCreator(integration);
|
||||
await client.disableAsync(input.duration);
|
||||
|
||||
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
|
||||
// We need to wait for the integration to be disabled before invalidating the cache
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => void innerHandler.invalidateAsync().then(resolve), 1000);
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
@@ -21,12 +21,18 @@ export const downloadsRouter = createTRPCRouter({
|
||||
.unstable_concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
|
||||
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
|
||||
const { data, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integration, {});
|
||||
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integration,
|
||||
timestamp,
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
@@ -37,17 +43,15 @@ export const downloadsRouter = createTRPCRouter({
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
||||
timestamp: Date;
|
||||
data: DownloadClientJobsAndStatus;
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
|
||||
const unsubscribe = channel.subscribe((data) => {
|
||||
const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((data) => {
|
||||
emit.next({
|
||||
integration,
|
||||
timestamp: new Date(),
|
||||
data,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { HealthMonitoring } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -12,14 +12,14 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
||||
const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
healthInfo,
|
||||
timestamp,
|
||||
healthInfo: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -31,8 +31,8 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
||||
const unsubscribe = channel.subscribe((healthInfo) => {
|
||||
const innerHandler = systemInfoRequestHandler.handler(integration, {});
|
||||
const unsubscribe = innerHandler.subscribe((healthInfo) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
healthInfo,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import type { Indexer } from "@homarr/integrations/types";
|
||||
import { logger } from "@homarr/log";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -20,14 +20,8 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const client = integrationCreator(integration);
|
||||
const indexers = await client.getIndexersAsync().catch((err) => {
|
||||
logger.error("indexer-manager router - ", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to fetch indexers for ${integration.name} (${integration.id})`,
|
||||
});
|
||||
});
|
||||
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
|
||||
const { data: indexers } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
@@ -43,11 +37,11 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const channel = createItemAndIntegrationChannel<Indexer[]>("indexerManager", integration.id);
|
||||
const unsubscribe = channel.subscribe((indexers) => {
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const innerHandler = indexerManagerRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((indexers) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
integrationId: integrationWithSecrets.id,
|
||||
indexers,
|
||||
});
|
||||
});
|
||||
@@ -60,7 +54,6 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
testAllIndexers: publicProcedure
|
||||
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
|
||||
@@ -1,53 +1,110 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { integrationCreator, MediaRequestStatus } from "@homarr/integrations";
|
||||
import type { MediaRequest } from "@homarr/integrations/types";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import {
|
||||
createManyIntegrationOfOneItemMiddleware,
|
||||
createOneIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaRequestsRouter = createTRPCRouter({
|
||||
getLatestRequests: publicProcedure
|
||||
.unstable_concat(
|
||||
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await Promise.all(
|
||||
input.integrationIds.map(async (integrationId) => {
|
||||
const channel = createItemAndIntegrationChannel<MediaRequestList>("mediaRequests-requestList", integrationId);
|
||||
return await channel.getAsync();
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results
|
||||
.flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
|
||||
.sort(({ status: statusA }, { status: statusB }) => {
|
||||
if (statusA === MediaRequestStatus.PendingApproval) {
|
||||
return -1;
|
||||
}
|
||||
if (statusB === MediaRequestStatus.PendingApproval) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}),
|
||||
subscribeToLatestRequests: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
requests: MediaRequest[];
|
||||
}>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integrationWithSecrets of ctx.integrations) {
|
||||
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||
const innerHandler = mediaRequestListRequestHandler.handler(integrationWithSecrets, {});
|
||||
const unsubscribe = innerHandler.subscribe((requests) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
requests,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
getStats: publicProcedure
|
||||
.unstable_concat(
|
||||
createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return await Promise.all(
|
||||
input.integrationIds.map(async (integrationId) => {
|
||||
const channel = createItemAndIntegrationChannel<MediaRequestStats>(
|
||||
"mediaRequests-requestStats",
|
||||
integrationId,
|
||||
);
|
||||
return await channel.getAsync();
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integration: {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
stats: results.flatMap((result) => result.data.stats),
|
||||
users: results
|
||||
.map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
|
||||
.flat()
|
||||
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
|
||||
integrations: results.map((result) => result.integration),
|
||||
};
|
||||
}),
|
||||
answerRequest: protectedProcedure
|
||||
.unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const integrationInstance = integrationCreator(integration);
|
||||
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
|
||||
|
||||
if (input.answer === "approve") {
|
||||
await integrationInstance.approveRequestAsync(input.requestId);
|
||||
await innerHandler.invalidateAsync();
|
||||
return;
|
||||
}
|
||||
await integrationInstance.declineRequestAsync(input.requestId);
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
@@ -17,11 +17,11 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
|
||||
const data = await channel.getAsync();
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {});
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
sessions: data?.data ?? [],
|
||||
sessions: data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -32,8 +32,9 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
|
||||
const unsubscribe = channel.subscribe((sessions) => {
|
||||
const innerHandler = mediaServerRequestHandler.handler(integration, {});
|
||||
|
||||
const unsubscribe = innerHandler.subscribe((sessions) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
data: sessions,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { integrationCreator } from "@homarr/integrations";
|
||||
import { homeAssistantEntityState } from "@homarr/redis";
|
||||
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
@@ -13,29 +13,45 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
|
||||
|
||||
export const smartHomeRouter = createTRPCRouter({
|
||||
subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => {
|
||||
return observable<{
|
||||
entityId: string;
|
||||
state: string;
|
||||
}>((emit) => {
|
||||
const unsubscribe = homeAssistantEntityState.subscribe((message) => {
|
||||
if (message.entityId !== input.entityId) {
|
||||
return;
|
||||
}
|
||||
emit.next(message);
|
||||
});
|
||||
entityState: publicProcedure
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.unstable_concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
subscribeEntityState: publicProcedure
|
||||
.unstable_concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.subscription(({ input, ctx }) => {
|
||||
return observable<{
|
||||
entityId: string;
|
||||
state: string;
|
||||
}>((emit) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(ctx.integration, {
|
||||
entityId: input.entityId,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((state) => {
|
||||
emit.next({ state, entityId: input.entityId });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
switchEntity: publicProcedure
|
||||
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = integrationCreator(integration);
|
||||
return await client.triggerToggleAsync(input.entityId);
|
||||
const success = await client.triggerToggleAsync(input.entityId);
|
||||
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
await innerHandler.invalidateAsync();
|
||||
|
||||
return success;
|
||||
}),
|
||||
executeAutomation: publicProcedure
|
||||
.unstable_concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||
|
||||
22
packages/api/src/schema-merger.ts
Normal file
22
packages/api/src/schema-merger.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AnyZodObject, ZodIntersection, ZodObject } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
export function convertIntersectionToZodObject<TIntersection extends ZodIntersection<AnyZodObject, AnyZodObject>>(
|
||||
intersection: TIntersection,
|
||||
) {
|
||||
const { _def } = intersection;
|
||||
|
||||
// Merge the shapes
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const mergedShape = { ..._def.left.shape, ..._def.right.shape };
|
||||
|
||||
// Return a new ZodObject
|
||||
return z.object(mergedShape) as unknown as TIntersection extends ZodIntersection<infer TLeft, infer TRight>
|
||||
? TLeft extends AnyZodObject
|
||||
? TRight extends AnyZodObject
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ZodObject<TLeft["shape"] & TRight["shape"], any, any, z.infer<TLeft> & z.infer<TRight>>
|
||||
: never
|
||||
: never
|
||||
: never;
|
||||
}
|
||||
16
packages/api/src/test/open-api.spec.ts
Normal file
16
packages/api/src/test/open-api.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { openApiDocument } from "../open-api";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
test("OpenAPI documentation should be generated", () => {
|
||||
// Arrange
|
||||
const base = "https://homarr.dev";
|
||||
|
||||
// Act
|
||||
const act = () => openApiDocument(base);
|
||||
|
||||
// Assert
|
||||
expect(act).not.toThrow();
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import type { OpenApiMeta } from "trpc-swagger";
|
||||
import type { OpenApiMeta } from "trpc-to-openapi";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { FlattenError } from "@homarr/common";
|
||||
|
||||
@@ -3,11 +3,11 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { accounts, users } from "@homarr/db/schema/sqlite";
|
||||
import { accounts, sessions, users } from "@homarr/db/schema/sqlite";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
|
||||
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
|
||||
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, accountsTable: accounts });
|
||||
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
|
||||
|
||||
return {
|
||||
...drizzleAdapter,
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
"prettier": "^3.4.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,11 @@ export class LdapClient {
|
||||
private static convertEntryPropertyToString(value: Entry[string]) {
|
||||
const firstValue = Array.isArray(value) ? (value[0] ?? "") : value;
|
||||
|
||||
if (firstValue instanceof Buffer) {
|
||||
return firstValue.toString("utf8");
|
||||
if (typeof firstValue === "string") {
|
||||
return firstValue;
|
||||
}
|
||||
return firstValue;
|
||||
|
||||
return firstValue.toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.18",
|
||||
"react": "^18.3.1",
|
||||
"tldts": "^6.1.63"
|
||||
"tldts": "^6.1.64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,20 @@ export const useTimeAgo = (timestamp: Date) => {
|
||||
|
||||
return timeAgo;
|
||||
};
|
||||
|
||||
export const useIntegrationConnected = (updatedAt: Date, { timeout = 30000 }) => {
|
||||
const [connected, setConnected] = useState(Math.abs(dayjs(updatedAt).diff()) < timeout);
|
||||
|
||||
useEffect(() => {
|
||||
setConnected(Math.abs(dayjs(updatedAt).diff()) < timeout);
|
||||
|
||||
const delayUntilTimeout = timeout - Math.abs(dayjs(updatedAt).diff());
|
||||
const timeoutRef = setTimeout(() => {
|
||||
setConnected(false);
|
||||
}, delayUntilTimeout);
|
||||
|
||||
return () => clearTimeout(timeoutRef);
|
||||
}, [updatedAt, timeout]);
|
||||
|
||||
return connected;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hashKey } from "@tanstack/query-core";
|
||||
|
||||
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
|
||||
return Object.keys(obj) as (keyof O)[];
|
||||
}
|
||||
@@ -7,3 +9,7 @@ type Entries<T> = {
|
||||
}[keyof T][];
|
||||
|
||||
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>;
|
||||
|
||||
export const hashObjectBase64 = (obj: object) => {
|
||||
return Buffer.from(hashKey([obj])).toString("base64");
|
||||
};
|
||||
|
||||
@@ -9,3 +9,5 @@ export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
|
||||
export type RemoveReadonly<T> = {
|
||||
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
|
||||
};
|
||||
|
||||
export type MaybeArray<T> = T | T[];
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/request-handler": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0"
|
||||
@@ -44,6 +45,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
|
||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||
import { mediaRequestsJob } from "./jobs/integrations/media-requests";
|
||||
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import type { RssFeed } from "./jobs/rss-feeds";
|
||||
@@ -23,7 +23,8 @@ export const jobGroup = createCronJobGroup({
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
downloads: downloadsJob,
|
||||
dnsHole: dnsHoleJob,
|
||||
mediaRequests: mediaRequestsJob,
|
||||
mediaRequestStats: mediaRequestStatsJob,
|
||||
mediaRequestList: mediaRequestListJob,
|
||||
rssFeeds: rssFeedsJob,
|
||||
indexerManager: indexerManagerJob,
|
||||
healthMonitoring: healthMonitoringJob,
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { logger } from "@homarr/log";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["dnsHoleSummary", "dnsHoleControls"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||
await integrationInstance
|
||||
.getSummaryAsync()
|
||||
.then(async (data) => {
|
||||
const channel = createItemAndIntegrationChannel<DnsHoleSummary>(itemForIntegration.kind, integration.id);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
})
|
||||
.catch((error) => logger.error(`Could not retrieve data for ${integration.name}: "${error}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
export const dnsHoleJob = createCronJob("dnsHole", EVERY_5_SECONDS).withCallback(
|
||||
createRequestIntegrationJobHandler(dnsHoleRequestHandler.handler, {
|
||||
widgetKinds: ["dnsHoleSummary", "dnsHoleControls"],
|
||||
getInput: {
|
||||
dnsHoleSummary: () => ({}),
|
||||
dnsHoleControls: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["downloads"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||
await integrationInstance
|
||||
.getClientJobsAndStatusAsync()
|
||||
.then(async (data) => {
|
||||
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
})
|
||||
.catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(
|
||||
createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
|
||||
widgetKinds: ["downloads"],
|
||||
getInput: {
|
||||
downloads: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["healthMonitoring"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const integration of itemForIntegration.integrations) {
|
||||
const openmediavault = integrationCreatorFromSecrets(integration.integration);
|
||||
const healthInfo = await openmediavault.getSystemInfoAsync();
|
||||
const channel = createItemAndIntegrationChannel("healthMonitoring", integration.integrationId);
|
||||
await channel.publishAndUpdateLastStateAsync(healthInfo);
|
||||
}
|
||||
}
|
||||
});
|
||||
export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SECONDS).withCallback(
|
||||
createRequestIntegrationJobHandler(systemInfoRequestHandler.handler, {
|
||||
widgetKinds: ["healthMonitoring"],
|
||||
getInput: {
|
||||
healthMonitoring: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,42 +1,16 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import { logger } from "@homarr/log";
|
||||
import { homeAssistantEntityState } from "@homarr/redis";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../../widgets";
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["smartHome-entityState"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
const integration = itemForIntegration.integrations[0]?.integration;
|
||||
if (!integration) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"smartHome-entityState">["options"]>(
|
||||
itemForIntegration.options,
|
||||
);
|
||||
|
||||
const homeAssistant = integrationCreatorFromSecrets(integration);
|
||||
const state = await homeAssistant.getEntityStateAsync(options.entityId);
|
||||
|
||||
if (!state.success) {
|
||||
logger.error("Unable to fetch data from Home Assistant");
|
||||
continue;
|
||||
}
|
||||
|
||||
await homeAssistantEntityState.publishAsync({
|
||||
entityId: options.entityId,
|
||||
state: state.data.state,
|
||||
});
|
||||
}
|
||||
});
|
||||
export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(smartHomeEntityStateRequestHandler.handler, {
|
||||
widgetKinds: ["smartHome-entityState"],
|
||||
getInput: {
|
||||
"smartHome-entityState": (options) => ({
|
||||
entityId: options.entityId,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["indexerManager"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||
await integrationInstance.getIndexersAsync();
|
||||
}
|
||||
}
|
||||
});
|
||||
export const indexerManagerJob = createCronJob("indexerManager", EVERY_5_MINUTES).withCallback(
|
||||
createRequestIntegrationJobHandler(indexerManagerRequestHandler.handler, {
|
||||
widgetKinds: ["indexerManager"],
|
||||
getInput: {
|
||||
indexerManager: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import dayjs from "dayjs";
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../../widgets";
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["calendar"],
|
||||
});
|
||||
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(calendarMonthRequestHandler.handler, {
|
||||
widgetKinds: ["calendar"],
|
||||
getInput: {
|
||||
// Request handler will run for all specified months
|
||||
calendar: (options) => {
|
||||
const inputs = [];
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
|
||||
const startOffset = -Number(options.filterPastMonths);
|
||||
const endOffset = Number(options.filterFutureMonths);
|
||||
|
||||
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
|
||||
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
|
||||
for (let offsetMonths = startOffset; offsetMonths <= endOffset; offsetMonths++) {
|
||||
const year = dayjs().subtract(offsetMonths, "months").year();
|
||||
const month = dayjs().subtract(offsetMonths, "months").month();
|
||||
|
||||
//Asserting the integration kind until all of them get implemented
|
||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||
inputs.push({
|
||||
year,
|
||||
month,
|
||||
releaseType: options.releaseType,
|
||||
});
|
||||
}
|
||||
|
||||
const events = await integrationInstance.getCalendarEventsAsync(start, end);
|
||||
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||
await cache.setAsync(events);
|
||||
}
|
||||
}
|
||||
});
|
||||
return inputs;
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"],
|
||||
});
|
||||
export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(mediaRequestStatsRequestHandler.handler, {
|
||||
widgetKinds: ["mediaRequests-requestStats"],
|
||||
getInput: {
|
||||
"mediaRequests-requestStats": () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const requestsIntegration = integrationCreatorFromSecrets(integration);
|
||||
|
||||
const mediaRequests = await requestsIntegration.getRequestsAsync();
|
||||
const requestsStats = await requestsIntegration.getStatsAsync();
|
||||
const requestsUsers = await requestsIntegration.getUsersAsync();
|
||||
const requestListChannel = createItemAndIntegrationChannel<MediaRequestList>(
|
||||
"mediaRequests-requestList",
|
||||
integration.id,
|
||||
);
|
||||
await requestListChannel.publishAndUpdateLastStateAsync({
|
||||
integration: { id: integration.id },
|
||||
medias: mediaRequests,
|
||||
});
|
||||
|
||||
const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
|
||||
"mediaRequests-requestStats",
|
||||
integration.id,
|
||||
);
|
||||
await requestStatsChannel.publishAndUpdateLastStateAsync({
|
||||
integration: { kind: integration.kind, name: integration.name },
|
||||
stats: requestsStats,
|
||||
users: requestsUsers,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
|
||||
widgetKinds: ["mediaRequests-requestList"],
|
||||
getInput: {
|
||||
"mediaRequests-requestList": () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries";
|
||||
import { integrationCreatorFromSecrets } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => {
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["mediaServer"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
const integrationInstance = integrationCreatorFromSecrets(integration);
|
||||
const streamSessions = await integrationInstance.getCurrentSessionsAsync();
|
||||
const channel = createItemAndIntegrationChannel("mediaServer", integration.id);
|
||||
await channel.publishAndUpdateLastStateAsync(streamSessions);
|
||||
}
|
||||
}
|
||||
});
|
||||
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(
|
||||
createRequestIntegrationJobHandler(mediaServerRequestHandler.handler, {
|
||||
widgetKinds: ["mediaServer"],
|
||||
getInput: {
|
||||
mediaServer: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ dotenv.config({ path: "../../.env" });
|
||||
export default {
|
||||
dialect: "mysql",
|
||||
schema: "./schema",
|
||||
casing: "snake_case",
|
||||
dbCredentials: {
|
||||
host: process.env.DB_HOST!,
|
||||
user: process.env.DB_USER!,
|
||||
|
||||
@@ -6,6 +6,7 @@ dotenv.config({ path: "../../.env" });
|
||||
export default {
|
||||
dialect: "sqlite",
|
||||
schema: "./schema",
|
||||
casing: "snake_case",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
dbCredentials: { url: process.env.DB_URL! },
|
||||
out: "./migrations/sqlite",
|
||||
|
||||
@@ -40,6 +40,7 @@ const initBetterSqlite = () => {
|
||||
database = drizzleSqlite(connection, {
|
||||
schema: sqliteSchema,
|
||||
logger: new WinstonDrizzleLogger(),
|
||||
casing: "snake_case",
|
||||
}) as unknown as never;
|
||||
};
|
||||
|
||||
@@ -61,6 +62,7 @@ const initMySQL2 = () => {
|
||||
schema: mysqlSchema,
|
||||
mode: "default",
|
||||
logger: new WinstonDrizzleLogger(),
|
||||
casing: "snake_case",
|
||||
}) as unknown as HomarrDatabase;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
ALTER TABLE `account` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
|
||||
ALTER TABLE `account` RENAME COLUMN `providerAccountId` TO `provider_account_id`;--> statement-breakpoint
|
||||
ALTER TABLE `apiKey` RENAME COLUMN `apiKey` TO `api_key`;--> statement-breakpoint
|
||||
ALTER TABLE `apiKey` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` RENAME COLUMN `groupId` TO `group_id`;--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
|
||||
ALTER TABLE `groupPermission` RENAME COLUMN `groupId` TO `group_id`;--> statement-breakpoint
|
||||
ALTER TABLE `iconRepository` RENAME COLUMN `iconRepository_id` TO `id`;--> statement-breakpoint
|
||||
ALTER TABLE `iconRepository` RENAME COLUMN `iconRepository_slug` TO `slug`;--> statement-breakpoint
|
||||
ALTER TABLE `icon` RENAME COLUMN `icon_id` TO `id`;--> statement-breakpoint
|
||||
ALTER TABLE `icon` RENAME COLUMN `icon_name` TO `name`;--> statement-breakpoint
|
||||
ALTER TABLE `icon` RENAME COLUMN `icon_url` TO `url`;--> statement-breakpoint
|
||||
ALTER TABLE `icon` RENAME COLUMN `icon_checksum` TO `checksum`;--> statement-breakpoint
|
||||
ALTER TABLE `icon` RENAME COLUMN `iconRepository_id` TO `icon_repository_id`;--> statement-breakpoint
|
||||
ALTER TABLE `serverSetting` RENAME COLUMN `key` TO `setting_key`;--> statement-breakpoint
|
||||
ALTER TABLE `session` RENAME COLUMN `sessionToken` TO `session_token`;--> statement-breakpoint
|
||||
ALTER TABLE `session` RENAME COLUMN `userId` TO `user_id`;--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME COLUMN `emailVerified` TO `email_verified`;--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME COLUMN `homeBoardId` TO `home_board_id`;--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME COLUMN `colorScheme` TO `color_scheme`;--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME COLUMN `firstDayOfWeek` TO `first_day_of_week`;--> statement-breakpoint
|
||||
ALTER TABLE `user` RENAME COLUMN `pingIconsEnabled` TO `ping_icons_enabled`;--> statement-breakpoint
|
||||
ALTER TABLE `serverSetting` DROP INDEX `serverSetting_key_unique`;--> statement-breakpoint
|
||||
ALTER TABLE `account` DROP FOREIGN KEY `account_userId_user_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `apiKey` DROP FOREIGN KEY `apiKey_userId_user_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` DROP FOREIGN KEY `groupMember_groupId_group_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` DROP FOREIGN KEY `groupMember_userId_user_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `groupPermission` DROP FOREIGN KEY `groupPermission_groupId_group_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `icon` DROP FOREIGN KEY `icon_iconRepository_id_iconRepository_iconRepository_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `session` DROP FOREIGN KEY `session_userId_user_id_fk`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `user` DROP FOREIGN KEY `user_homeBoardId_board_id_fk`;
|
||||
--> statement-breakpoint
|
||||
DROP INDEX `userId_idx` ON `account`;--> statement-breakpoint
|
||||
DROP INDEX `user_id_idx` ON `session`;--> statement-breakpoint
|
||||
ALTER TABLE `account` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `iconRepository` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `icon` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `serverSetting` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `session` DROP PRIMARY KEY;--> statement-breakpoint
|
||||
ALTER TABLE `account` ADD PRIMARY KEY(`provider`,`provider_account_id`);--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` ADD PRIMARY KEY(`group_id`,`user_id`);--> statement-breakpoint
|
||||
ALTER TABLE `iconRepository` ADD PRIMARY KEY(`id`);--> statement-breakpoint
|
||||
ALTER TABLE `icon` ADD PRIMARY KEY(`id`);--> statement-breakpoint
|
||||
ALTER TABLE `serverSetting` ADD PRIMARY KEY(`setting_key`);--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD PRIMARY KEY(`session_token`);--> statement-breakpoint
|
||||
ALTER TABLE `serverSetting` ADD CONSTRAINT `serverSetting_settingKey_unique` UNIQUE(`setting_key`);--> statement-breakpoint
|
||||
ALTER TABLE `account` ADD CONSTRAINT `account_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `apiKey` ADD CONSTRAINT `apiKey_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `groupPermission` ADD CONSTRAINT `groupPermission_group_id_group_id_fk` FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `icon` ADD CONSTRAINT `icon_icon_repository_id_iconRepository_id_fk` FOREIGN KEY (`icon_repository_id`) REFERENCES `iconRepository`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `session` ADD CONSTRAINT `session_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD CONSTRAINT `user_home_board_id_board_id_fk` FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `user_id_idx` ON `session` (`user_id`);
|
||||
1650
packages/db/migrations/mysql/meta/0016_snapshot.json
Normal file
1650
packages/db/migrations/mysql/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1730653393442,
|
||||
"tag": "0015_unknown_firedrake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "5",
|
||||
"when": 1732212709518,
|
||||
"tag": "0016_change_all_to_snake_case",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const migrateAsync = async () => {
|
||||
const db = drizzle(mysql2, {
|
||||
mode: "default",
|
||||
schema: mysqlSchema,
|
||||
casing: "snake_case",
|
||||
});
|
||||
|
||||
await migrate(db, { migrationsFolder });
|
||||
|
||||
102
packages/db/migrations/sqlite/0016_change_all_to_snake_case.sql
Normal file
102
packages/db/migrations/sqlite/0016_change_all_to_snake_case.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
ALTER TABLE `iconRepository` RENAME COLUMN "iconRepository_id" TO "id";--> statement-breakpoint
|
||||
ALTER TABLE `iconRepository` RENAME COLUMN "iconRepository_slug" TO "slug";--> statement-breakpoint
|
||||
ALTER TABLE `serverSetting` RENAME COLUMN "key" TO "setting_key";--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_account` (
|
||||
`user_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`provider` text NOT NULL,
|
||||
`provider_account_id` text NOT NULL,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` integer,
|
||||
`token_type` text,
|
||||
`scope` text,
|
||||
`id_token` text,
|
||||
`session_state` text,
|
||||
PRIMARY KEY(`provider`, `provider_account_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_account`("user_id", "type", "provider", "provider_account_id", "refresh_token", "access_token", "expires_at", "token_type", "scope", "id_token", "session_state") SELECT "userId", "type", "provider", "providerAccountId", "refresh_token", "access_token", "expires_at", "token_type", "scope", "id_token", "session_state" FROM `account`;--> statement-breakpoint
|
||||
DROP TABLE `account`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `userId_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_apiKey` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`api_key` text NOT NULL,
|
||||
`salt` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_apiKey`("id", "api_key", "salt", "user_id") SELECT "id", "apiKey", "salt", "userId" FROM `apiKey`;--> statement-breakpoint
|
||||
DROP TABLE `apiKey`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_apiKey` RENAME TO `apiKey`;--> statement-breakpoint
|
||||
CREATE TABLE `__new_groupMember` (
|
||||
`group_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
PRIMARY KEY(`group_id`, `user_id`),
|
||||
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_groupMember`("group_id", "user_id") SELECT "groupId", "userId" FROM `groupMember`;--> statement-breakpoint
|
||||
DROP TABLE `groupMember`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_groupMember` RENAME TO `groupMember`;--> statement-breakpoint
|
||||
CREATE TABLE `__new_groupPermission` (
|
||||
`group_id` text NOT NULL,
|
||||
`permission` text NOT NULL,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_groupPermission`("group_id", "permission") SELECT "groupId", "permission" FROM `groupPermission`;--> statement-breakpoint
|
||||
DROP TABLE `groupPermission`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_groupPermission` RENAME TO `groupPermission`;--> statement-breakpoint
|
||||
CREATE TABLE `__new_icon` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`checksum` text NOT NULL,
|
||||
`icon_repository_id` text NOT NULL,
|
||||
FOREIGN KEY (`icon_repository_id`) REFERENCES `iconRepository`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_icon`("id", "name", "url", "checksum", "icon_repository_id") SELECT "icon_id", "icon_name", "icon_url", "icon_checksum", "iconRepository_id" FROM `icon`;--> statement-breakpoint
|
||||
DROP TABLE `icon`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_icon` RENAME TO `icon`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `serverSetting_key_unique`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `serverSetting_settingKey_unique` ON `serverSetting` (`setting_key`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_session` (
|
||||
`session_token` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`expires` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_session`("session_token", "user_id", "expires") SELECT "sessionToken", "userId", "expires" FROM `session`;--> statement-breakpoint
|
||||
DROP TABLE `session`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint
|
||||
CREATE INDEX `user_id_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `__new_user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text,
|
||||
`email` text,
|
||||
`email_verified` integer,
|
||||
`image` text,
|
||||
`password` text,
|
||||
`salt` text,
|
||||
`provider` text DEFAULT 'credentials' NOT NULL,
|
||||
`home_board_id` text,
|
||||
`color_scheme` text DEFAULT 'dark' NOT NULL,
|
||||
`first_day_of_week` integer DEFAULT 1 NOT NULL,
|
||||
`ping_icons_enabled` integer DEFAULT false NOT NULL,
|
||||
FOREIGN KEY (`home_board_id`) REFERENCES `board`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_user`("id", "name", "email", "email_verified", "image", "password", "salt", "provider", "home_board_id", "color_scheme", "first_day_of_week", "ping_icons_enabled") SELECT "id", "name", "email", "emailVerified", "image", "password", "salt", "provider", "homeBoardId", "colorScheme", "firstDayOfWeek", "pingIconsEnabled" FROM `user`;--> statement-breakpoint
|
||||
DROP TABLE `user`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
1579
packages/db/migrations/sqlite/meta/0016_snapshot.json
Normal file
1579
packages/db/migrations/sqlite/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1730653336134,
|
||||
"tag": "0015_superb_psylocke",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1732210918783,
|
||||
"tag": "0016_change_all_to_snake_case",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const migrationsFolder = process.argv[2] ?? ".";
|
||||
const migrateAsync = async () => {
|
||||
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
const db = drizzle(sqlite, { schema, casing: "snake_case" });
|
||||
|
||||
migrate(db, { migrationsFolder });
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"./client": "./client.ts",
|
||||
"./schema/sqlite": "./schema/sqlite.ts",
|
||||
"./test": "./test/index.ts",
|
||||
"./queries": "./queries/index.ts"
|
||||
"./queries": "./queries/index.ts",
|
||||
"./validationSchemas": "./validationSchemas.ts"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
@@ -42,11 +43,12 @@
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@testcontainers/mysql": "^10.15.0",
|
||||
"better-sqlite3": "^11.5.0",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"mysql2": "3.11.4"
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"mysql2": "3.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -55,8 +57,8 @@
|
||||
"@types/better-sqlite3": "7.6.12",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "^9.15.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier": "^3.4.1",
|
||||
"tsx": "4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,10 @@ const customBlob = customType<{ data: Buffer }>({
|
||||
});
|
||||
|
||||
export const apiKeys = mysqlTable("apiKey", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
salt: text("salt").notNull(),
|
||||
userId: varchar("userId", { length: 64 })
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
apiKey: text().notNull(),
|
||||
salt: text().notNull(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references((): AnyMySqlColumn => users.id, {
|
||||
onDelete: "cascade",
|
||||
@@ -50,38 +50,38 @@ export const apiKeys = mysqlTable("apiKey", {
|
||||
});
|
||||
|
||||
export const users = mysqlTable("user", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: text("name"),
|
||||
email: text("email"),
|
||||
emailVerified: timestamp("emailVerified"),
|
||||
image: text("image"),
|
||||
password: text("password"),
|
||||
salt: text("salt"),
|
||||
provider: varchar("provider", { length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: text(),
|
||||
email: text(),
|
||||
emailVerified: timestamp(),
|
||||
image: text(),
|
||||
password: text(),
|
||||
salt: text(),
|
||||
provider: varchar({ length: 64 }).$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
|
||||
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
export const accounts = mysqlTable(
|
||||
"account",
|
||||
{
|
||||
userId: varchar("userId", { length: 64 })
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<AdapterAccount["type"]>().notNull(),
|
||||
provider: varchar("provider", { length: 64 }).notNull(),
|
||||
providerAccountId: varchar("providerAccountId", { length: 64 }).notNull(),
|
||||
refresh_token: text("refresh_token"),
|
||||
access_token: text("access_token"),
|
||||
expires_at: int("expires_at"),
|
||||
token_type: text("token_type"),
|
||||
scope: text("scope"),
|
||||
id_token: text("id_token"),
|
||||
session_state: text("session_state"),
|
||||
type: text().$type<AdapterAccount["type"]>().notNull(),
|
||||
provider: varchar({ length: 64 }).notNull(),
|
||||
providerAccountId: varchar({ length: 64 }).notNull(),
|
||||
refresh_token: text(),
|
||||
access_token: text(),
|
||||
expires_at: int(),
|
||||
token_type: text(),
|
||||
scope: text(),
|
||||
id_token: text(),
|
||||
session_state: text(),
|
||||
},
|
||||
(account) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -94,11 +94,11 @@ export const accounts = mysqlTable(
|
||||
export const sessions = mysqlTable(
|
||||
"session",
|
||||
{
|
||||
sessionToken: varchar("sessionToken", { length: 512 }).notNull().primaryKey(),
|
||||
userId: varchar("userId", { length: 64 })
|
||||
sessionToken: varchar({ length: 512 }).notNull().primaryKey(),
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expires: timestamp("expires").notNull(),
|
||||
expires: timestamp().notNull(),
|
||||
},
|
||||
(session) => ({
|
||||
userIdIdx: index("user_id_idx").on(session.userId),
|
||||
@@ -108,9 +108,9 @@ export const sessions = mysqlTable(
|
||||
export const verificationTokens = mysqlTable(
|
||||
"verificationToken",
|
||||
{
|
||||
identifier: varchar("identifier", { length: 64 }).notNull(),
|
||||
token: varchar("token", { length: 512 }).notNull(),
|
||||
expires: timestamp("expires").notNull(),
|
||||
identifier: varchar({ length: 64 }).notNull(),
|
||||
token: varchar({ length: 512 }).notNull(),
|
||||
expires: timestamp().notNull(),
|
||||
},
|
||||
(verificationToken) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -122,10 +122,10 @@ export const verificationTokens = mysqlTable(
|
||||
export const groupMembers = mysqlTable(
|
||||
"groupMember",
|
||||
{
|
||||
groupId: varchar("groupId", { length: 64 })
|
||||
groupId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
userId: varchar("userId", { length: 64 })
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
},
|
||||
@@ -137,46 +137,46 @@ export const groupMembers = mysqlTable(
|
||||
);
|
||||
|
||||
export const groups = mysqlTable("group", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 64 }).unique().notNull(),
|
||||
ownerId: varchar("owner_id", { length: 64 }).references(() => users.id, {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: varchar({ length: 64 }).unique().notNull(),
|
||||
ownerId: varchar({ length: 64 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
export const groupPermissions = mysqlTable("groupPermission", {
|
||||
groupId: varchar("groupId", { length: 64 })
|
||||
groupId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<GroupPermissionKey>().notNull(),
|
||||
permission: text().$type<GroupPermissionKey>().notNull(),
|
||||
});
|
||||
|
||||
export const invites = mysqlTable("invite", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
token: varchar("token", { length: 512 }).notNull().unique(),
|
||||
expirationDate: timestamp("expiration_date").notNull(),
|
||||
creatorId: varchar("creator_id", { length: 64 })
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
token: varchar({ length: 512 }).notNull().unique(),
|
||||
expirationDate: timestamp().notNull(),
|
||||
creatorId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const medias = mysqlTable("media", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 512 }).notNull(),
|
||||
content: customBlob("content").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
size: int("size").notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).notNull().defaultNow(),
|
||||
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: varchar({ length: 512 }).notNull(),
|
||||
content: customBlob().notNull(),
|
||||
contentType: text().notNull(),
|
||||
size: int().notNull(),
|
||||
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
|
||||
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const integrations = mysqlTable(
|
||||
"integration",
|
||||
{
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
url: text("url").notNull(),
|
||||
kind: varchar("kind", { length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: text().notNull(),
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -186,12 +186,12 @@ export const integrations = mysqlTable(
|
||||
export const integrationSecrets = mysqlTable(
|
||||
"integrationSecret",
|
||||
{
|
||||
kind: varchar("kind", { length: 16 }).$type<IntegrationSecretKind>().notNull(),
|
||||
value: text("value").$type<`${string}.${string}`>().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
kind: varchar({ length: 16 }).$type<IntegrationSecretKind>().notNull(),
|
||||
value: text().$type<`${string}.${string}`>().notNull(),
|
||||
updatedAt: timestamp()
|
||||
.$onUpdateFn(() => new Date())
|
||||
.notNull(),
|
||||
integrationId: varchar("integration_id", { length: 64 })
|
||||
integrationId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
},
|
||||
@@ -207,13 +207,13 @@ export const integrationSecrets = mysqlTable(
|
||||
export const integrationUserPermissions = mysqlTable(
|
||||
"integrationUserPermission",
|
||||
{
|
||||
integrationId: varchar("integration_id", { length: 64 })
|
||||
integrationId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 64 })
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||
permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -225,13 +225,13 @@ export const integrationUserPermissions = mysqlTable(
|
||||
export const integrationGroupPermissions = mysqlTable(
|
||||
"integrationGroupPermissions",
|
||||
{
|
||||
integrationId: varchar("integration_id", { length: 64 })
|
||||
integrationId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
groupId: varchar("group_id", { length: 64 })
|
||||
groupId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||
permission: varchar({ length: 128 }).$type<IntegrationPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -242,46 +242,40 @@ export const integrationGroupPermissions = mysqlTable(
|
||||
);
|
||||
|
||||
export const boards = mysqlTable("board", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("name", { length: 256 }).unique().notNull(),
|
||||
isPublic: boolean("is_public").default(false).notNull(),
|
||||
creatorId: varchar("creator_id", { length: 64 }).references(() => users.id, {
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: varchar({ length: 256 }).unique().notNull(),
|
||||
isPublic: boolean().default(false).notNull(),
|
||||
creatorId: varchar({ length: 64 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
pageTitle: text("page_title"),
|
||||
metaTitle: text("meta_title"),
|
||||
logoImageUrl: text("logo_image_url"),
|
||||
faviconImageUrl: text("favicon_image_url"),
|
||||
backgroundImageUrl: text("background_image_url"),
|
||||
backgroundImageAttachment: text("background_image_attachment")
|
||||
pageTitle: text(),
|
||||
metaTitle: text(),
|
||||
logoImageUrl: text(),
|
||||
faviconImageUrl: text(),
|
||||
backgroundImageUrl: text(),
|
||||
backgroundImageAttachment: text()
|
||||
.$type<BackgroundImageAttachment>()
|
||||
.default(backgroundImageAttachments.defaultValue)
|
||||
.notNull(),
|
||||
backgroundImageRepeat: text("background_image_repeat")
|
||||
.$type<BackgroundImageRepeat>()
|
||||
.default(backgroundImageRepeats.defaultValue)
|
||||
.notNull(),
|
||||
backgroundImageSize: text("background_image_size")
|
||||
.$type<BackgroundImageSize>()
|
||||
.default(backgroundImageSizes.defaultValue)
|
||||
.notNull(),
|
||||
primaryColor: text("primary_color").default("#fa5252").notNull(),
|
||||
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
|
||||
opacity: int("opacity").default(100).notNull(),
|
||||
customCss: text("custom_css"),
|
||||
columnCount: int("column_count").default(10).notNull(),
|
||||
backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
|
||||
backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
|
||||
primaryColor: text().default("#fa5252").notNull(),
|
||||
secondaryColor: text().default("#fd7e14").notNull(),
|
||||
opacity: int().default(100).notNull(),
|
||||
customCss: text(),
|
||||
columnCount: int().default(10).notNull(),
|
||||
});
|
||||
|
||||
export const boardUserPermissions = mysqlTable(
|
||||
"boardUserPermission",
|
||||
{
|
||||
boardId: varchar("board_id", { length: 64 })
|
||||
boardId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 64 })
|
||||
userId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
|
||||
permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -293,13 +287,13 @@ export const boardUserPermissions = mysqlTable(
|
||||
export const boardGroupPermissions = mysqlTable(
|
||||
"boardGroupPermission",
|
||||
{
|
||||
boardId: varchar("board_id", { length: 64 })
|
||||
boardId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
groupId: varchar("group_id", { length: 64 })
|
||||
groupId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: varchar("permission", { length: 128 }).$type<BoardPermission>().notNull(),
|
||||
permission: varchar({ length: 128 }).$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -309,50 +303,50 @@ export const boardGroupPermissions = mysqlTable(
|
||||
);
|
||||
|
||||
export const sections = mysqlTable("section", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
boardId: varchar("board_id", { length: 64 })
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
boardId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<SectionKind>().notNull(),
|
||||
xOffset: int("x_offset").notNull(),
|
||||
yOffset: int("y_offset").notNull(),
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
name: text("name"),
|
||||
parentSectionId: varchar("parent_section_id", { length: 64 }).references((): AnyMySqlColumn => sections.id, {
|
||||
kind: text().$type<SectionKind>().notNull(),
|
||||
xOffset: int().notNull(),
|
||||
yOffset: int().notNull(),
|
||||
width: int(),
|
||||
height: int(),
|
||||
name: text(),
|
||||
parentSectionId: varchar({ length: 64 }).references((): AnyMySqlColumn => sections.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const items = mysqlTable("item", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
sectionId: varchar("section_id", { length: 64 })
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
sectionId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => sections.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<WidgetKind>().notNull(),
|
||||
xOffset: int("x_offset").notNull(),
|
||||
yOffset: int("y_offset").notNull(),
|
||||
width: int("width").notNull(),
|
||||
height: int("height").notNull(),
|
||||
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
kind: text().$type<WidgetKind>().notNull(),
|
||||
xOffset: int().notNull(),
|
||||
yOffset: int().notNull(),
|
||||
width: int().notNull(),
|
||||
height: int().notNull(),
|
||||
options: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const apps = mysqlTable("app", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
iconUrl: text("icon_url").notNull(),
|
||||
href: text("href"),
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
iconUrl: text().notNull(),
|
||||
href: text(),
|
||||
});
|
||||
|
||||
export const integrationItems = mysqlTable(
|
||||
"integration_item",
|
||||
{
|
||||
itemId: varchar("item_id", { length: 64 })
|
||||
itemId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: "cascade" }),
|
||||
integrationId: varchar("integration_id", { length: 64 })
|
||||
integrationId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
},
|
||||
@@ -364,23 +358,23 @@ export const integrationItems = mysqlTable(
|
||||
);
|
||||
|
||||
export const icons = mysqlTable("icon", {
|
||||
id: varchar("icon_id", { length: 64 }).notNull().primaryKey(),
|
||||
name: varchar("icon_name", { length: 250 }).notNull(),
|
||||
url: text("icon_url").notNull(),
|
||||
checksum: text("icon_checksum").notNull(),
|
||||
iconRepositoryId: varchar("iconRepository_id", { length: 64 })
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
name: varchar({ length: 250 }).notNull(),
|
||||
url: text().notNull(),
|
||||
checksum: text().notNull(),
|
||||
iconRepositoryId: varchar({ length: 64 })
|
||||
.notNull()
|
||||
.references(() => iconRepositories.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const iconRepositories = mysqlTable("iconRepository", {
|
||||
id: varchar("iconRepository_id", { length: 64 }).notNull().primaryKey(),
|
||||
slug: varchar("iconRepository_slug", { length: 150 }).notNull(),
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
slug: varchar({ length: 150 }).notNull(),
|
||||
});
|
||||
|
||||
export const serverSettings = mysqlTable("serverSetting", {
|
||||
settingKey: varchar("key", { length: 64 }).notNull().unique().primaryKey(),
|
||||
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
|
||||
settingKey: varchar({ length: 64 }).notNull().unique().primaryKey(),
|
||||
value: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
@@ -391,14 +385,14 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
}));
|
||||
|
||||
export const searchEngines = mysqlTable("search_engine", {
|
||||
id: varchar("id", { length: 64 }).notNull().primaryKey(),
|
||||
iconUrl: text("icon_url").notNull(),
|
||||
name: varchar("name", { length: 64 }).notNull(),
|
||||
short: varchar("short", { length: 8 }).notNull(),
|
||||
description: text("description"),
|
||||
urlTemplate: text("url_template"),
|
||||
type: varchar("type", { length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
|
||||
integrationId: varchar("integration_id", { length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
|
||||
id: varchar({ length: 64 }).notNull().primaryKey(),
|
||||
iconUrl: text().notNull(),
|
||||
name: varchar({ length: 64 }).notNull(),
|
||||
short: varchar({ length: 8 }).notNull(),
|
||||
description: text(),
|
||||
urlTemplate: text(),
|
||||
type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
|
||||
integrationId: varchar({ length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DayOfWeek } from "@mantine/dates";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { blob, index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { blob, index, int, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
||||
import type {
|
||||
@@ -23,10 +23,10 @@ import type {
|
||||
} from "@homarr/definitions";
|
||||
|
||||
export const apiKeys = sqliteTable("apiKey", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
apiKey: text("apiKey").notNull(),
|
||||
salt: text("salt").notNull(),
|
||||
userId: text("userId")
|
||||
id: text().notNull().primaryKey(),
|
||||
apiKey: text().notNull(),
|
||||
salt: text().notNull(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references((): AnySQLiteColumn => users.id, {
|
||||
onDelete: "cascade",
|
||||
@@ -34,38 +34,38 @@ export const apiKeys = sqliteTable("apiKey", {
|
||||
});
|
||||
|
||||
export const users = sqliteTable("user", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name"),
|
||||
email: text("email"),
|
||||
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
|
||||
image: text("image"),
|
||||
password: text("password"),
|
||||
salt: text("salt"),
|
||||
provider: text("provider").$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text(),
|
||||
email: text(),
|
||||
emailVerified: int({ mode: "timestamp_ms" }),
|
||||
image: text(),
|
||||
password: text(),
|
||||
salt: text(),
|
||||
provider: text().$type<SupportedAuthProvider>().default("credentials").notNull(),
|
||||
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
|
||||
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
|
||||
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
userId: text("userId")
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: text("type").$type<AdapterAccount["type"]>().notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
providerAccountId: text("providerAccountId").notNull(),
|
||||
refresh_token: text("refresh_token"),
|
||||
access_token: text("access_token"),
|
||||
expires_at: integer("expires_at"),
|
||||
token_type: text("token_type"),
|
||||
scope: text("scope"),
|
||||
id_token: text("id_token"),
|
||||
session_state: text("session_state"),
|
||||
type: text().$type<AdapterAccount["type"]>().notNull(),
|
||||
provider: text().notNull(),
|
||||
providerAccountId: text().notNull(),
|
||||
refresh_token: text(),
|
||||
access_token: text(),
|
||||
expires_at: int(),
|
||||
token_type: text(),
|
||||
scope: text(),
|
||||
id_token: text(),
|
||||
session_state: text(),
|
||||
},
|
||||
(account) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -78,11 +78,11 @@ export const accounts = sqliteTable(
|
||||
export const sessions = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
sessionToken: text("sessionToken").notNull().primaryKey(),
|
||||
userId: text("userId")
|
||||
sessionToken: text().notNull().primaryKey(),
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
||||
expires: int({ mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(session) => ({
|
||||
userIdIdx: index("user_id_idx").on(session.userId),
|
||||
@@ -92,9 +92,9 @@ export const sessions = sqliteTable(
|
||||
export const verificationTokens = sqliteTable(
|
||||
"verificationToken",
|
||||
{
|
||||
identifier: text("identifier").notNull(),
|
||||
token: text("token").notNull(),
|
||||
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
|
||||
identifier: text().notNull(),
|
||||
token: text().notNull(),
|
||||
expires: int({ mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(verificationToken) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -106,10 +106,10 @@ export const verificationTokens = sqliteTable(
|
||||
export const groupMembers = sqliteTable(
|
||||
"groupMember",
|
||||
{
|
||||
groupId: text("groupId")
|
||||
groupId: text()
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
userId: text("userId")
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
},
|
||||
@@ -121,50 +121,50 @@ export const groupMembers = sqliteTable(
|
||||
);
|
||||
|
||||
export const groups = sqliteTable("group", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").unique().notNull(),
|
||||
ownerId: text("owner_id").references(() => users.id, {
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text().unique().notNull(),
|
||||
ownerId: text().references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
export const groupPermissions = sqliteTable("groupPermission", {
|
||||
groupId: text("groupId")
|
||||
groupId: text()
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<GroupPermissionKey>().notNull(),
|
||||
permission: text().$type<GroupPermissionKey>().notNull(),
|
||||
});
|
||||
|
||||
export const invites = sqliteTable("invite", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
token: text("token").notNull().unique(),
|
||||
expirationDate: int("expiration_date", {
|
||||
id: text().notNull().primaryKey(),
|
||||
token: text().notNull().unique(),
|
||||
expirationDate: int({
|
||||
mode: "timestamp",
|
||||
}).notNull(),
|
||||
creatorId: text("creator_id")
|
||||
creatorId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const medias = sqliteTable("media", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
content: blob("content", { mode: "buffer" }).$type<Buffer>().notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
size: int("size").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text().notNull(),
|
||||
content: blob({ mode: "buffer" }).$type<Buffer>().notNull(),
|
||||
contentType: text().notNull(),
|
||||
size: int().notNull(),
|
||||
createdAt: int({ mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
creatorId: text("creator_id").references(() => users.id, { onDelete: "set null" }),
|
||||
creatorId: text().references(() => users.id, { onDelete: "set null" }),
|
||||
});
|
||||
|
||||
export const integrations = sqliteTable(
|
||||
"integration",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
url: text("url").notNull(),
|
||||
kind: text("kind").$type<IntegrationKind>().notNull(),
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text().notNull(),
|
||||
url: text().notNull(),
|
||||
kind: text().$type<IntegrationKind>().notNull(),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -174,12 +174,12 @@ export const integrations = sqliteTable(
|
||||
export const integrationSecrets = sqliteTable(
|
||||
"integrationSecret",
|
||||
{
|
||||
kind: text("kind").$type<IntegrationSecretKind>().notNull(),
|
||||
value: text("value").$type<`${string}.${string}`>().notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
kind: text().$type<IntegrationSecretKind>().notNull(),
|
||||
value: text().$type<`${string}.${string}`>().notNull(),
|
||||
updatedAt: int({ mode: "timestamp" })
|
||||
.$onUpdateFn(() => new Date())
|
||||
.notNull(),
|
||||
integrationId: text("integration_id")
|
||||
integrationId: text()
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
},
|
||||
@@ -195,13 +195,13 @@ export const integrationSecrets = sqliteTable(
|
||||
export const integrationUserPermissions = sqliteTable(
|
||||
"integrationUserPermission",
|
||||
{
|
||||
integrationId: text("integration_id")
|
||||
integrationId: text()
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||
permission: text().$type<IntegrationPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -213,13 +213,13 @@ export const integrationUserPermissions = sqliteTable(
|
||||
export const integrationGroupPermissions = sqliteTable(
|
||||
"integrationGroupPermissions",
|
||||
{
|
||||
integrationId: text("integration_id")
|
||||
integrationId: text()
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
groupId: text("group_id")
|
||||
groupId: text()
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<IntegrationPermission>().notNull(),
|
||||
permission: text().$type<IntegrationPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -229,46 +229,40 @@ export const integrationGroupPermissions = sqliteTable(
|
||||
);
|
||||
|
||||
export const boards = sqliteTable("board", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").unique().notNull(),
|
||||
isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(),
|
||||
creatorId: text("creator_id").references(() => users.id, {
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text().unique().notNull(),
|
||||
isPublic: int({ mode: "boolean" }).default(false).notNull(),
|
||||
creatorId: text().references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
pageTitle: text("page_title"),
|
||||
metaTitle: text("meta_title"),
|
||||
logoImageUrl: text("logo_image_url"),
|
||||
faviconImageUrl: text("favicon_image_url"),
|
||||
backgroundImageUrl: text("background_image_url"),
|
||||
backgroundImageAttachment: text("background_image_attachment")
|
||||
pageTitle: text(),
|
||||
metaTitle: text(),
|
||||
logoImageUrl: text(),
|
||||
faviconImageUrl: text(),
|
||||
backgroundImageUrl: text(),
|
||||
backgroundImageAttachment: text()
|
||||
.$type<BackgroundImageAttachment>()
|
||||
.default(backgroundImageAttachments.defaultValue)
|
||||
.notNull(),
|
||||
backgroundImageRepeat: text("background_image_repeat")
|
||||
.$type<BackgroundImageRepeat>()
|
||||
.default(backgroundImageRepeats.defaultValue)
|
||||
.notNull(),
|
||||
backgroundImageSize: text("background_image_size")
|
||||
.$type<BackgroundImageSize>()
|
||||
.default(backgroundImageSizes.defaultValue)
|
||||
.notNull(),
|
||||
primaryColor: text("primary_color").default("#fa5252").notNull(),
|
||||
secondaryColor: text("secondary_color").default("#fd7e14").notNull(),
|
||||
opacity: int("opacity").default(100).notNull(),
|
||||
customCss: text("custom_css"),
|
||||
columnCount: int("column_count").default(10).notNull(),
|
||||
backgroundImageRepeat: text().$type<BackgroundImageRepeat>().default(backgroundImageRepeats.defaultValue).notNull(),
|
||||
backgroundImageSize: text().$type<BackgroundImageSize>().default(backgroundImageSizes.defaultValue).notNull(),
|
||||
primaryColor: text().default("#fa5252").notNull(),
|
||||
secondaryColor: text().default("#fd7e14").notNull(),
|
||||
opacity: int().default(100).notNull(),
|
||||
customCss: text(),
|
||||
columnCount: int().default(10).notNull(),
|
||||
});
|
||||
|
||||
export const boardUserPermissions = sqliteTable(
|
||||
"boardUserPermission",
|
||||
{
|
||||
boardId: text("board_id")
|
||||
boardId: text()
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
userId: text()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<BoardPermission>().notNull(),
|
||||
permission: text().$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -280,13 +274,13 @@ export const boardUserPermissions = sqliteTable(
|
||||
export const boardGroupPermissions = sqliteTable(
|
||||
"boardGroupPermission",
|
||||
{
|
||||
boardId: text("board_id")
|
||||
boardId: text()
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
groupId: text("group_id")
|
||||
groupId: text()
|
||||
.notNull()
|
||||
.references(() => groups.id, { onDelete: "cascade" }),
|
||||
permission: text("permission").$type<BoardPermission>().notNull(),
|
||||
permission: text().$type<BoardPermission>().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
compoundKey: primaryKey({
|
||||
@@ -296,50 +290,50 @@ export const boardGroupPermissions = sqliteTable(
|
||||
);
|
||||
|
||||
export const sections = sqliteTable("section", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
boardId: text("board_id")
|
||||
id: text().notNull().primaryKey(),
|
||||
boardId: text()
|
||||
.notNull()
|
||||
.references(() => boards.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<SectionKind>().notNull(),
|
||||
xOffset: int("x_offset").notNull(),
|
||||
yOffset: int("y_offset").notNull(),
|
||||
width: int("width"),
|
||||
height: int("height"),
|
||||
name: text("name"),
|
||||
parentSectionId: text("parent_section_id").references((): AnySQLiteColumn => sections.id, {
|
||||
kind: text().$type<SectionKind>().notNull(),
|
||||
xOffset: int().notNull(),
|
||||
yOffset: int().notNull(),
|
||||
width: int(),
|
||||
height: int(),
|
||||
name: text(),
|
||||
parentSectionId: text().references((): AnySQLiteColumn => sections.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const items = sqliteTable("item", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
sectionId: text("section_id")
|
||||
id: text().notNull().primaryKey(),
|
||||
sectionId: text()
|
||||
.notNull()
|
||||
.references(() => sections.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<WidgetKind>().notNull(),
|
||||
xOffset: int("x_offset").notNull(),
|
||||
yOffset: int("y_offset").notNull(),
|
||||
width: int("width").notNull(),
|
||||
height: int("height").notNull(),
|
||||
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
advancedOptions: text("advanced_options").default('{"json": {}}').notNull(), // empty superjson object
|
||||
kind: text().$type<WidgetKind>().notNull(),
|
||||
xOffset: int().notNull(),
|
||||
yOffset: int().notNull(),
|
||||
width: int().notNull(),
|
||||
height: int().notNull(),
|
||||
options: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||
advancedOptions: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const apps = sqliteTable("app", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
iconUrl: text("icon_url").notNull(),
|
||||
href: text("href"),
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text().notNull(),
|
||||
description: text(),
|
||||
iconUrl: text().notNull(),
|
||||
href: text(),
|
||||
});
|
||||
|
||||
export const integrationItems = sqliteTable(
|
||||
"integration_item",
|
||||
{
|
||||
itemId: text("item_id")
|
||||
itemId: text()
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: "cascade" }),
|
||||
integrationId: text("integration_id")
|
||||
integrationId: text()
|
||||
.notNull()
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
},
|
||||
@@ -351,23 +345,23 @@ export const integrationItems = sqliteTable(
|
||||
);
|
||||
|
||||
export const icons = sqliteTable("icon", {
|
||||
id: text("icon_id").notNull().primaryKey(),
|
||||
name: text("icon_name").notNull(),
|
||||
url: text("icon_url").notNull(),
|
||||
checksum: text("icon_checksum").notNull(),
|
||||
iconRepositoryId: text("iconRepository_id")
|
||||
id: text().notNull().primaryKey(),
|
||||
name: text().notNull(),
|
||||
url: text().notNull(),
|
||||
checksum: text().notNull(),
|
||||
iconRepositoryId: text()
|
||||
.notNull()
|
||||
.references(() => iconRepositories.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const iconRepositories = sqliteTable("iconRepository", {
|
||||
id: text("iconRepository_id").notNull().primaryKey(),
|
||||
slug: text("iconRepository_slug").notNull(),
|
||||
id: text().notNull().primaryKey(),
|
||||
slug: text().notNull(),
|
||||
});
|
||||
|
||||
export const serverSettings = sqliteTable("serverSetting", {
|
||||
settingKey: text("key").notNull().unique().primaryKey(),
|
||||
value: text("value").default('{"json": {}}').notNull(), // empty superjson object
|
||||
settingKey: text().notNull().unique().primaryKey(),
|
||||
value: text().default('{"json": {}}').notNull(), // empty superjson object
|
||||
});
|
||||
|
||||
export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
@@ -378,14 +372,14 @@ export const apiKeyRelations = relations(apiKeys, ({ one }) => ({
|
||||
}));
|
||||
|
||||
export const searchEngines = sqliteTable("search_engine", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
iconUrl: text("icon_url").notNull(),
|
||||
name: text("name").notNull(),
|
||||
short: text("short").notNull(),
|
||||
description: text("description"),
|
||||
urlTemplate: text("url_template"),
|
||||
type: text("type").$type<SearchEngineType>().notNull().default("generic"),
|
||||
integrationId: text("integration_id").references(() => integrations.id, { onDelete: "cascade" }),
|
||||
id: text().notNull().primaryKey(),
|
||||
iconUrl: text().notNull(),
|
||||
name: text().notNull(),
|
||||
short: text().notNull(),
|
||||
description: text(),
|
||||
urlTemplate: text(),
|
||||
type: text().$type<SearchEngineType>().notNull().default("generic"),
|
||||
integrationId: text().references(() => integrations.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { schema } from "..";
|
||||
|
||||
export const createDb = (debug?: boolean) => {
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = drizzle(sqlite, { schema, logger: debug });
|
||||
const db = drizzle(sqlite, { schema, logger: debug, casing: "snake_case" });
|
||||
migrate(db, {
|
||||
migrationsFolder: "./packages/db/migrations/sqlite",
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("Mysql Migration", () => {
|
||||
const database = drizzle(connection, {
|
||||
schema: mysqlSchema,
|
||||
mode: "default",
|
||||
casing: "snake_case",
|
||||
});
|
||||
|
||||
// Run migrations and check if it works
|
||||
|
||||
11
packages/db/validationSchemas.ts
Normal file
11
packages/db/validationSchemas.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
|
||||
import { apps, boards, groups, invites, searchEngines, serverSettings, users } from "./schema/sqlite";
|
||||
|
||||
export const selectAppSchema = createSelectSchema(apps);
|
||||
export const selectBoardSchema = createSelectSchema(boards);
|
||||
export const selectGroupSchema = createSelectSchema(groups);
|
||||
export const selectInviteSchema = createSelectSchema(invites);
|
||||
export const selectSearchEnginesSchema = createSelectSchema(searchEngines);
|
||||
export const selectSeverSettingsSchema = createSelectSchema(serverSettings);
|
||||
export const selectUserSchema = createSelectSchema(users);
|
||||
@@ -30,6 +30,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
"dependencies": {
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@mantine/form": "^7.14.1"
|
||||
"@mantine/form": "^7.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,6 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"eslint": "^9.15.0",
|
||||
"typescript": "^5.6.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
|
||||
type ReleaseType = (typeof radarrReleaseTypes)[number];
|
||||
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
|
||||
|
||||
export interface CalendarEvent {
|
||||
name: string;
|
||||
subName: string;
|
||||
date: Date;
|
||||
dates?: { type: ReleaseType; date: Date }[];
|
||||
dates?: { type: RadarrReleaseType; date: Date }[];
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
mediaInformation?: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user