mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81c6fb0d56 | |||
| aeb30ad096 |
@@ -37,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||
import Integrations from "@/features/integration/pages/integrations.tsx";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
@@ -102,6 +103,7 @@ export default function App() {
|
||||
<Route path={"sharing"} element={<Shares />} />
|
||||
<Route path={"security"} element={<Security />} />
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"integrations"} element={<Integrations />} />
|
||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||
</Route>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IconKey,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconPlug,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -116,6 +117,12 @@ const groupedData: DataGroup[] = [
|
||||
path: "/settings/ai",
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
label: "Integrations",
|
||||
icon: IconPlug,
|
||||
path: "/settings/integrations",
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Add link")} withArrow>
|
||||
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { matchIntegrationLink } from "@docmost/editor-ext";
|
||||
|
||||
export const handlePaste = (
|
||||
editor: Editor,
|
||||
@@ -13,6 +14,21 @@ export const handlePaste = (
|
||||
) => {
|
||||
const clipboardData = event.clipboardData.getData("text/plain");
|
||||
|
||||
const integrationMatch = matchIntegrationLink(clipboardData.trim());
|
||||
if (integrationMatch && editor.state.selection.empty) {
|
||||
event.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setIntegrationLink({
|
||||
url: clipboardData.trim(),
|
||||
provider: integrationMatch.provider,
|
||||
status: "pending",
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||
event.preventDefault();
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
.card {
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--mantine-color-blue-4);
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import {
|
||||
Card,
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Avatar,
|
||||
Skeleton,
|
||||
Anchor,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useCallback, memo } from "react";
|
||||
import { unfurlUrl } from "@/features/integration/services/integration-service";
|
||||
import classes from "./integration-link-view.module.css";
|
||||
|
||||
const providerIcons: Record<string, string> = {
|
||||
github: "https://github.githubassets.com/favicons/favicon-dark.svg",
|
||||
gitlab: "https://gitlab.com/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8571da3c2571592f63571e0c5571.png",
|
||||
jira: "https://wac-cdn.atlassian.com/assets/img/favicons/atlassian/favicon.png",
|
||||
linear: "https://linear.app/favicon.ico",
|
||||
google_docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
|
||||
figma: "https://static.figma.com/app/icon/1/favicon.png",
|
||||
};
|
||||
|
||||
function IntegrationLinkView(props: any) {
|
||||
const { node, updateAttributes, editor } = props;
|
||||
const { url, provider, unfurlData, status } = node.attrs;
|
||||
|
||||
const doUnfurl = useCallback(async () => {
|
||||
if (status !== "pending" || !url) return;
|
||||
|
||||
try {
|
||||
const result = await unfurlUrl({ url });
|
||||
if (result) {
|
||||
updateAttributes({
|
||||
unfurlData: result,
|
||||
status: "loaded",
|
||||
});
|
||||
} else {
|
||||
updateAttributes({ status: "error" });
|
||||
}
|
||||
} catch {
|
||||
updateAttributes({ status: "error" });
|
||||
}
|
||||
}, [url, status, updateAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "pending") {
|
||||
doUnfurl();
|
||||
}
|
||||
}, [status, doUnfurl]);
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card className={classes.card} withBorder padding="sm" radius="sm">
|
||||
<Group gap="sm">
|
||||
<Skeleton circle height={24} />
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Skeleton height={14} width="60%" />
|
||||
<Skeleton height={10} width="80%" />
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error" || !unfurlData) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card className={classes.card} withBorder padding="sm" radius="sm">
|
||||
<Anchor href={url} target="_blank" rel="noopener" size="sm">
|
||||
{url}
|
||||
</Anchor>
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const iconUrl = providerIcons[provider] ?? undefined;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle="">
|
||||
<Card
|
||||
className={classes.card}
|
||||
withBorder
|
||||
padding="sm"
|
||||
radius="sm"
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style={{ textDecoration: "none", color: "inherit" }}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{unfurlData.authorAvatarUrl ? (
|
||||
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" />
|
||||
) : iconUrl ? (
|
||||
<Avatar src={iconUrl} size={28} radius="sm" />
|
||||
) : null}
|
||||
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{unfurlData.title}
|
||||
</Text>
|
||||
{unfurlData.status && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={unfurlData.statusColor ?? "gray"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{unfurlData.status}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{unfurlData.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{unfurlData.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
{iconUrl && (
|
||||
<Avatar src={iconUrl} size={14} radius="sm" />
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{unfurlData.provider}
|
||||
</Text>
|
||||
{unfurlData.author && (
|
||||
<Text size="xs" c="dimmed">
|
||||
· {unfurlData.author}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(IntegrationLinkView);
|
||||
@@ -27,7 +27,7 @@ export const LinkPreviewPanel = ({
|
||||
<>
|
||||
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
||||
<Flex align="center">
|
||||
<Tooltip label={url}>
|
||||
<Tooltip label={url} withArrow withinPortal={false}>
|
||||
<Anchor
|
||||
href={url}
|
||||
target="_blank"
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Highlight,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
IntegrationLink,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -60,6 +61,7 @@ import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -231,6 +233,9 @@ export const mainExtensions = [
|
||||
Subpages.configure({
|
||||
view: SubpagesView,
|
||||
}),
|
||||
IntegrationLink.configure({
|
||||
view: IntegrationLinkView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconBrandSlack,
|
||||
IconBrandGitlab,
|
||||
IconPuzzle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IntegrationDefinition,
|
||||
Integration,
|
||||
} from "../types/integration.types";
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
github: IconBrandGithub,
|
||||
slack: IconBrandSlack,
|
||||
gitlab: IconBrandGitlab,
|
||||
};
|
||||
|
||||
type IntegrationCardProps = {
|
||||
definition: IntegrationDefinition;
|
||||
installation?: Integration;
|
||||
onInstall: (type: string) => void;
|
||||
onUninstall: (integrationId: string) => void;
|
||||
onConfigure: (integration: Integration) => void;
|
||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function IntegrationCard({
|
||||
definition,
|
||||
installation,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onConfigure,
|
||||
onToggle,
|
||||
}: IntegrationCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const Icon = iconMap[definition.icon] ?? IconPuzzle;
|
||||
const isInstalled = !!installation;
|
||||
|
||||
return (
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Group gap="sm">
|
||||
<Icon size={28} stroke={1.5} />
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{definition.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{definition.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" mb="md">
|
||||
{definition.capabilities.map((cap) => (
|
||||
<Badge key={cap} size="xs" variant="light">
|
||||
{cap}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{isInstalled ? (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
checked={installation.isEnabled}
|
||||
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => onConfigure(installation)}
|
||||
>
|
||||
{t("Configure")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => onUninstall(installation.id)}
|
||||
>
|
||||
{t("Uninstall")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => onInstall(definition.type)}
|
||||
>
|
||||
{t("Install")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Integration, ConnectionStatus } from "../types/integration.types";
|
||||
import {
|
||||
useConnectionStatus,
|
||||
useDisconnectIntegration,
|
||||
} from "../queries/integration-query";
|
||||
import * as integrationService from "../services/integration-service";
|
||||
|
||||
type IntegrationSettingsModalProps = {
|
||||
integration: Integration | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function IntegrationSettingsModal({
|
||||
integration,
|
||||
opened,
|
||||
onClose,
|
||||
}: IntegrationSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: connectionStatus } = useConnectionStatus(integration?.id);
|
||||
const disconnectMutation = useDisconnectIntegration();
|
||||
|
||||
if (!integration) return null;
|
||||
|
||||
const handleConnect = async () => {
|
||||
try {
|
||||
const result = await integrationService.getOAuthAuthorizeUrl({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
window.location.href = result.authorizationUrl;
|
||||
} catch (error) {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to start OAuth connection"),
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
await disconnectMutation.mutateAsync({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
};
|
||||
|
||||
const hasOAuth = true;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{hasOAuth && (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Connection")}
|
||||
</Text>
|
||||
{connectionStatus?.connected ? (
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="green">
|
||||
{t("Connected")}
|
||||
{connectionStatus.providerUserId &&
|
||||
` (${connectionStatus.providerUserId})`}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={handleDisconnect}
|
||||
loading={disconnectMutation.isPending}
|
||||
>
|
||||
{t("Disconnect")}
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={handleConnect}>
|
||||
{t("Connect")} {integration.type}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback } from "react";
|
||||
import { getAppName } from "@/lib/config";
|
||||
import SettingsTitle from "@/components/settings/settings-title";
|
||||
import IntegrationCard from "../components/integration-card";
|
||||
import IntegrationSettingsModal from "../components/integration-settings-modal";
|
||||
import {
|
||||
useAvailableIntegrations,
|
||||
useInstalledIntegrations,
|
||||
useInstallIntegration,
|
||||
useUninstallIntegration,
|
||||
useUpdateIntegrationSettings,
|
||||
} from "../queries/integration-query";
|
||||
import { Integration } from "../types/integration.types";
|
||||
|
||||
export default function Integrations() {
|
||||
const { t } = useTranslation();
|
||||
const { data: available, isLoading: loadingAvailable } =
|
||||
useAvailableIntegrations();
|
||||
const { data: installed, isLoading: loadingInstalled } =
|
||||
useInstalledIntegrations();
|
||||
const installMutation = useInstallIntegration();
|
||||
const uninstallMutation = useUninstallIntegration();
|
||||
const updateMutation = useUpdateIntegrationSettings();
|
||||
|
||||
const [configuring, setConfiguring] = useState<Integration | null>(null);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
(type: string) => {
|
||||
installMutation.mutate({ type });
|
||||
},
|
||||
[installMutation],
|
||||
);
|
||||
|
||||
const handleUninstall = useCallback(
|
||||
(integrationId: string) => {
|
||||
uninstallMutation.mutate({ integrationId });
|
||||
},
|
||||
[uninstallMutation],
|
||||
);
|
||||
|
||||
const handleConfigure = useCallback((integration: Integration) => {
|
||||
setConfiguring(integration);
|
||||
}, []);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(integration: Integration, enabled: boolean) => {
|
||||
updateMutation.mutate({
|
||||
integrationId: integration.id,
|
||||
isEnabled: enabled,
|
||||
});
|
||||
},
|
||||
[updateMutation],
|
||||
);
|
||||
|
||||
const isLoading = loadingAvailable || loadingInstalled;
|
||||
const error = new URLSearchParams(window.location.search).get("error");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("Integrations")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
|
||||
<SettingsTitle title={t("Integrations")} />
|
||||
|
||||
{error === "oauth_failed" && (
|
||||
<Alert color="red" mb="md">
|
||||
{t("OAuth connection failed. Please try again.")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : !available?.length ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("No integrations available.")}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
{available.map((def) => {
|
||||
const installation = installed?.find((i) => i.type === def.type);
|
||||
return (
|
||||
<IntegrationCard
|
||||
key={def.type}
|
||||
definition={def}
|
||||
installation={installation}
|
||||
onInstall={handleInstall}
|
||||
onUninstall={handleUninstall}
|
||||
onConfigure={handleConfigure}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<IntegrationSettingsModal
|
||||
integration={configuring}
|
||||
opened={!!configuring}
|
||||
onClose={() => setConfiguring(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import * as integrationService from "../services/integration-service";
|
||||
|
||||
export function useAvailableIntegrations() {
|
||||
return useQuery({
|
||||
queryKey: ["available-integrations"],
|
||||
queryFn: integrationService.getAvailableIntegrations,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstalledIntegrations() {
|
||||
return useQuery({
|
||||
queryKey: ["installed-integrations"],
|
||||
queryFn: integrationService.getInstalledIntegrations,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallIntegration() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: integrationService.installIntegration,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Integration installed successfully") });
|
||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to install integration"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallIntegration() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: integrationService.uninstallIntegration,
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: t("Integration uninstalled successfully"),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to uninstall integration"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateIntegrationSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: integrationService.updateIntegrationSettings,
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Integration updated successfully") });
|
||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to update integration"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConnectionStatus(integrationId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["integration-connection", integrationId],
|
||||
queryFn: () =>
|
||||
integrationService.getConnectionStatus({
|
||||
integrationId: integrationId!,
|
||||
}),
|
||||
enabled: !!integrationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisconnectIntegration() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: integrationService.disconnectIntegration,
|
||||
onSuccess: (_data, variables) => {
|
||||
notifications.show({ message: t("Integration disconnected") });
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["integration-connection", variables.integrationId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({
|
||||
message: errorMessage || t("Failed to disconnect integration"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IntegrationDefinition,
|
||||
Integration,
|
||||
ConnectionStatus,
|
||||
UnfurlResult,
|
||||
} from "../types/integration.types";
|
||||
|
||||
export async function getAvailableIntegrations(): Promise<
|
||||
IntegrationDefinition[]
|
||||
> {
|
||||
const req = await api.post<IntegrationDefinition[]>(
|
||||
"/integrations/available",
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getInstalledIntegrations(): Promise<Integration[]> {
|
||||
const req = await api.post<Integration[]>("/integrations/list");
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function installIntegration(data: {
|
||||
type: string;
|
||||
}): Promise<Integration> {
|
||||
const req = await api.post<Integration>("/integrations/install", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function uninstallIntegration(data: {
|
||||
integrationId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/integrations/uninstall", data);
|
||||
}
|
||||
|
||||
export async function updateIntegrationSettings(data: {
|
||||
integrationId: string;
|
||||
settings?: Record<string, any>;
|
||||
isEnabled?: boolean;
|
||||
}): Promise<Integration> {
|
||||
const req = await api.post<Integration>("/integrations/update", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getConnectionStatus(data: {
|
||||
integrationId: string;
|
||||
}): Promise<ConnectionStatus> {
|
||||
const req = await api.post<ConnectionStatus>(
|
||||
"/integrations/connection/status",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function getOAuthAuthorizeUrl(data: {
|
||||
integrationId: string;
|
||||
}): Promise<{ authorizationUrl: string }> {
|
||||
const req = await api.post<{ authorizationUrl: string }>(
|
||||
"/integrations/oauth/authorize",
|
||||
data,
|
||||
);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function disconnectIntegration(data: {
|
||||
integrationId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/integrations/oauth/disconnect", data);
|
||||
}
|
||||
|
||||
export async function unfurlUrl(data: {
|
||||
url: string;
|
||||
}): Promise<UnfurlResult | null> {
|
||||
const req = await api.post<{ data: UnfurlResult | null }>(
|
||||
"/integrations/unfurl",
|
||||
data,
|
||||
);
|
||||
return req.data.data;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
|
||||
|
||||
export type IntegrationDefinition = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
capabilities: IntegrationCapability[];
|
||||
};
|
||||
|
||||
export type Integration = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
isEnabled: boolean;
|
||||
settings: Record<string, any> | null;
|
||||
installedById: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConnectionStatus = {
|
||||
connected: boolean;
|
||||
providerUserId?: string;
|
||||
};
|
||||
|
||||
export type UnfurlResult = {
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
provider: string;
|
||||
providerIcon?: string;
|
||||
status?: string;
|
||||
statusColor?: string;
|
||||
author?: string;
|
||||
authorAvatarUrl?: string;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
@@ -25,6 +25,7 @@ const APP_ROUTE = {
|
||||
SPACES: "/settings/spaces",
|
||||
BILLING: "/settings/billing",
|
||||
SECURITY: "/settings/security",
|
||||
INTEGRATIONS: "/settings/integrations",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { IntegrationModule } from './integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
@@ -45,6 +47,7 @@ export class CoreModule implements NestModule {
|
||||
{ path: 'health', method: RequestMethod.GET },
|
||||
{ path: 'health/live', method: RequestMethod.GET },
|
||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum IntegrationType {
|
||||
SLACK = 'slack',
|
||||
GITHUB = 'github',
|
||||
GITLAB = 'gitlab',
|
||||
JIRA = 'jira',
|
||||
LINEAR = 'linear',
|
||||
GOOGLE_DOCS = 'google_docs',
|
||||
FIGMA = 'figma',
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
function deriveEncryptionKey(appSecret: string): Buffer {
|
||||
return crypto.createHash('sha256').update(appSecret).digest();
|
||||
}
|
||||
|
||||
export function encryptToken(token: string, appSecret: string): string {
|
||||
const algorithm = 'aes-256-gcm';
|
||||
const key = deriveEncryptionKey(appSecret);
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
let encrypted = cipher.update(token, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
export function decryptToken(encryptedToken: string, appSecret: string): string {
|
||||
const algorithm = 'aes-256-gcm';
|
||||
const key = deriveEncryptionKey(appSecret);
|
||||
|
||||
const parts = encryptedToken.split(':');
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const slackSettingsSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
channelName: z.string().optional(),
|
||||
notifyOn: z
|
||||
.array(z.enum(['page.created', 'page.updated', 'page.deleted']))
|
||||
.default(['page.created']),
|
||||
});
|
||||
|
||||
export const githubSettingsSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
org: z.string().optional(),
|
||||
defaultRepo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const gitlabSettingsSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
group: z.string().optional(),
|
||||
defaultProject: z.string().optional(),
|
||||
});
|
||||
|
||||
export const jiraSettingsSchema = z.object({
|
||||
baseUrl: z.string().url().optional(),
|
||||
cloudId: z.string().optional(),
|
||||
siteName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const linearSettingsSchema = z.object({
|
||||
teamId: z.string().optional(),
|
||||
});
|
||||
|
||||
const integrationSettingsSchemas: Record<string, z.ZodType> = {
|
||||
slack: slackSettingsSchema,
|
||||
github: githubSettingsSchema,
|
||||
gitlab: gitlabSettingsSchema,
|
||||
jira: jiraSettingsSchema,
|
||||
linear: linearSettingsSchema,
|
||||
};
|
||||
|
||||
export function validateIntegrationSettings(
|
||||
type: string,
|
||||
settings: unknown,
|
||||
): { success: true; data: Record<string, any> } | { success: false; error: string } {
|
||||
const schema = integrationSettingsSchemas[type];
|
||||
if (!schema) {
|
||||
if (settings && typeof settings === 'object') {
|
||||
return { success: true, data: settings as Record<string, any> };
|
||||
}
|
||||
return { success: true, data: {} };
|
||||
}
|
||||
|
||||
const result = schema.safeParse(settings);
|
||||
if (!result.success) {
|
||||
const messages = result.error.issues.map(
|
||||
(i) => `${i.path.join('.')}: ${i.message}`,
|
||||
);
|
||||
return { success: false, error: messages.join(', ') };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
export type SlackSettings = z.infer<typeof slackSettingsSchema>;
|
||||
export type GithubSettings = z.infer<typeof githubSettingsSchema>;
|
||||
export type GitlabSettings = z.infer<typeof gitlabSettingsSchema>;
|
||||
export type JiraSettings = z.infer<typeof jiraSettingsSchema>;
|
||||
export type LinearSettings = z.infer<typeof linearSettingsSchema>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class InstallIntegrationDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
type: string;
|
||||
}
|
||||
|
||||
export class UninstallIntegrationDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
integrationId: string;
|
||||
}
|
||||
|
||||
export class UpdateIntegrationDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
integrationId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
settings?: Record<string, any>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class IntegrationIdDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
integrationId: string;
|
||||
}
|
||||
|
||||
export class UnfurlDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class OAuthAuthorizeDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
integrationId: string;
|
||||
}
|
||||
|
||||
export class OAuthDisconnectDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
integrationId: string;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
||||
import { IntegrationRepo } from './repos/integration.repo';
|
||||
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationConnectionService {
|
||||
constructor(
|
||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
||||
private readonly integrationRepo: IntegrationRepo,
|
||||
) {}
|
||||
|
||||
async getConnectionStatus(
|
||||
integrationId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<{ connected: boolean; providerUserId?: string }> {
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
if (!integration || integration.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Integration not found');
|
||||
}
|
||||
|
||||
const connection = await this.connectionRepo.findByIntegrationAndUser(
|
||||
integrationId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return {
|
||||
connected: !!connection,
|
||||
providerUserId: connection?.providerUserId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async findByIntegrationAndUser(
|
||||
integrationId: string,
|
||||
userId: string,
|
||||
): Promise<IntegrationConnection | undefined> {
|
||||
return this.connectionRepo.findByIntegrationAndUser(integrationId, userId);
|
||||
}
|
||||
|
||||
async findByWorkspaceTypeAndUser(
|
||||
workspaceId: string,
|
||||
integrationType: string,
|
||||
userId: string,
|
||||
): Promise<IntegrationConnection | undefined> {
|
||||
return this.connectionRepo.findByWorkspaceTypeAndUser(
|
||||
workspaceId,
|
||||
integrationType,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
async disconnect(
|
||||
integrationId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
if (!integration || integration.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Integration not found');
|
||||
}
|
||||
|
||||
await this.connectionRepo.deleteByIntegrationAndUser(
|
||||
integrationId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { IntegrationConnectionService } from './integration-connection.service';
|
||||
import {
|
||||
InstallIntegrationDto,
|
||||
UninstallIntegrationDto,
|
||||
UpdateIntegrationDto,
|
||||
IntegrationIdDto,
|
||||
} from './dto/integration.dto';
|
||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
|
||||
@Controller('integrations')
|
||||
export class IntegrationController {
|
||||
constructor(
|
||||
private readonly integrationService: IntegrationService,
|
||||
private readonly connectionService: IntegrationConnectionService,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('available')
|
||||
async getAvailableIntegrations() {
|
||||
return this.integrationService.getAvailableIntegrations();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async getInstalledIntegrations(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.integrationService.getInstalledIntegrations(workspace.id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('install')
|
||||
async install(
|
||||
@Body() dto: InstallIntegrationDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.integrationService.install(dto.type, workspace.id, user.id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('uninstall')
|
||||
async uninstall(
|
||||
@Body() dto: UninstallIntegrationDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.integrationService.uninstall(dto.integrationId, workspace.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(
|
||||
@Body() dto: UpdateIntegrationDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
if (
|
||||
ability.cannot(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.Settings,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.integrationService.update(dto.integrationId, workspace.id, {
|
||||
settings: dto.settings,
|
||||
isEnabled: dto.isEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('connection/status')
|
||||
async getConnectionStatus(
|
||||
@Body() dto: IntegrationIdDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
return this.connectionService.getConnectionStatus(
|
||||
dto.integrationId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
||||
import { EventName } from '../../common/events/event.contants';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationListener {
|
||||
constructor(
|
||||
@InjectQueue(QueueName.INTEGRATION_QUEUE)
|
||||
private readonly integrationQueue: Queue,
|
||||
) {}
|
||||
|
||||
@OnEvent(EventName.PAGE_CREATED)
|
||||
async onPageCreated(payload: any) {
|
||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
||||
eventName: EventName.PAGE_CREATED,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_UPDATED)
|
||||
async onPageUpdated(payload: any) {
|
||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
||||
eventName: EventName.PAGE_UPDATED,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent(EventName.PAGE_DELETED)
|
||||
async onPageDeleted(payload: any) {
|
||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
||||
eventName: EventName.PAGE_DELETED,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IntegrationRegistry } from './registry/integration-registry';
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { IntegrationConnectionService } from './integration-connection.service';
|
||||
import { IntegrationController } from './integration.controller';
|
||||
import { OAuthController } from './oauth/oauth.controller';
|
||||
import { OAuthService } from './oauth/oauth.service';
|
||||
import { UnfurlController } from './unfurl/unfurl.controller';
|
||||
import { UnfurlService } from './unfurl/unfurl.service';
|
||||
import { IntegrationRepo } from './repos/integration.repo';
|
||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
||||
import { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
|
||||
import { IntegrationListener } from './integration.listener';
|
||||
import { IntegrationProcessor } from './integration.processor';
|
||||
|
||||
@Module({
|
||||
controllers: [IntegrationController, OAuthController, UnfurlController],
|
||||
providers: [
|
||||
IntegrationRegistry,
|
||||
IntegrationService,
|
||||
IntegrationConnectionService,
|
||||
OAuthService,
|
||||
UnfurlService,
|
||||
IntegrationRepo,
|
||||
IntegrationConnectionRepo,
|
||||
IntegrationWebhookRepo,
|
||||
IntegrationListener,
|
||||
IntegrationProcessor,
|
||||
],
|
||||
exports: [
|
||||
IntegrationRegistry,
|
||||
IntegrationService,
|
||||
IntegrationConnectionService,
|
||||
OAuthService,
|
||||
IntegrationRepo,
|
||||
IntegrationConnectionRepo,
|
||||
],
|
||||
})
|
||||
export class IntegrationModule {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
||||
import { IntegrationRegistry } from './registry/integration-registry';
|
||||
import { IntegrationRepo } from './repos/integration.repo';
|
||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
||||
import { OAuthService } from './oauth/oauth.service';
|
||||
|
||||
@Processor(QueueName.INTEGRATION_QUEUE)
|
||||
export class IntegrationProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(IntegrationProcessor.name);
|
||||
|
||||
constructor(
|
||||
private readonly registry: IntegrationRegistry,
|
||||
private readonly integrationRepo: IntegrationRepo,
|
||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
||||
private readonly oauthService: OAuthService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job): Promise<void> {
|
||||
switch (job.name) {
|
||||
case QueueJob.INTEGRATION_EVENT:
|
||||
await this.handleIntegrationEvent(job);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(`Unknown job: ${job.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIntegrationEvent(job: Job): Promise<void> {
|
||||
const { eventName, workspaceId, ...payload } = job.data;
|
||||
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations =
|
||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
||||
|
||||
for (const integration of integrations) {
|
||||
const provider = this.registry.getProvider(integration.type);
|
||||
if (!provider?.handleEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const connections = await this.connectionRepo.findByIntegration(
|
||||
integration.id,
|
||||
);
|
||||
|
||||
const connection = connections[0];
|
||||
let accessToken: string | undefined;
|
||||
|
||||
if (connection) {
|
||||
accessToken = await this.oauthService.getValidAccessToken(connection);
|
||||
}
|
||||
|
||||
await provider.handleEvent({
|
||||
eventName,
|
||||
payload,
|
||||
integration: {
|
||||
id: integration.id,
|
||||
type: integration.type,
|
||||
settings: integration.settings as Record<string, any> | null,
|
||||
},
|
||||
connection: connection
|
||||
? { accessToken, userId: connection.userId }
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { IntegrationRepo } from './repos/integration.repo';
|
||||
import { IntegrationRegistry } from './registry/integration-registry';
|
||||
import { Integration } from '@docmost/db/types/entity.types';
|
||||
import { validateIntegrationSettings } from './dto/integration-settings.schema';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationService {
|
||||
constructor(
|
||||
private readonly integrationRepo: IntegrationRepo,
|
||||
private readonly registry: IntegrationRegistry,
|
||||
) {}
|
||||
|
||||
async getAvailableIntegrations() {
|
||||
return this.registry.getAvailableIntegrations();
|
||||
}
|
||||
|
||||
async getInstalledIntegrations(workspaceId: string): Promise<Integration[]> {
|
||||
return this.integrationRepo.findAllByWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
async findById(integrationId: string): Promise<Integration | undefined> {
|
||||
return this.integrationRepo.findById(integrationId);
|
||||
}
|
||||
|
||||
async install(
|
||||
type: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<Integration> {
|
||||
const provider = this.registry.getProvider(type);
|
||||
if (!provider) {
|
||||
throw new BadRequestException(`Unknown integration type: ${type}`);
|
||||
}
|
||||
|
||||
const existing = await this.integrationRepo.findByWorkspaceAndType(
|
||||
workspaceId,
|
||||
type,
|
||||
);
|
||||
if (existing) {
|
||||
throw new BadRequestException(
|
||||
`Integration "${type}" is already installed`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.integrationRepo.insertOrRestore({
|
||||
type,
|
||||
workspaceId,
|
||||
installedById: userId,
|
||||
});
|
||||
}
|
||||
|
||||
async uninstall(integrationId: string, workspaceId: string): Promise<void> {
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
if (!integration || integration.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Integration not found');
|
||||
}
|
||||
await this.integrationRepo.softDelete(integrationId);
|
||||
}
|
||||
|
||||
async update(
|
||||
integrationId: string,
|
||||
workspaceId: string,
|
||||
data: { settings?: Record<string, any>; isEnabled?: boolean },
|
||||
): Promise<Integration> {
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
if (!integration || integration.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Integration not found');
|
||||
}
|
||||
|
||||
if (data.settings !== undefined) {
|
||||
const validation = validateIntegrationSettings(
|
||||
integration.type,
|
||||
data.settings,
|
||||
);
|
||||
if (validation.success === false) {
|
||||
throw new BadRequestException(`Invalid settings: ${validation.error}`);
|
||||
}
|
||||
data.settings = validation.data;
|
||||
}
|
||||
|
||||
return this.integrationRepo.update(integrationId, {
|
||||
...(data.settings !== undefined && { settings: data.settings }),
|
||||
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { OAuthService } from './oauth.service';
|
||||
import { OAuthAuthorizeDto, OAuthDisconnectDto } from '../dto/integration.dto';
|
||||
import { IntegrationConnectionService } from '../integration-connection.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
@Controller('integrations/oauth')
|
||||
export class OAuthController {
|
||||
private readonly logger = new Logger(OAuthController.name);
|
||||
|
||||
constructor(
|
||||
private readonly oauthService: OAuthService,
|
||||
private readonly connectionService: IntegrationConnectionService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('authorize')
|
||||
async authorize(
|
||||
@Body() dto: OAuthAuthorizeDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const { authorizationUrl } = await this.oauthService.getAuthorizationUrl(
|
||||
dto.integrationId,
|
||||
workspace.id,
|
||||
user.id,
|
||||
);
|
||||
|
||||
return { authorizationUrl };
|
||||
}
|
||||
|
||||
@Get(':type/callback')
|
||||
async callback(
|
||||
@Param('type') type: string,
|
||||
@Query('code') code: string,
|
||||
@Query('state') state: string,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
if (!code || !state) {
|
||||
throw new BadRequestException('Missing code or state parameter');
|
||||
}
|
||||
|
||||
const statePayload = this.oauthService.verifySignedState(state);
|
||||
if (!statePayload) {
|
||||
throw new BadRequestException('Invalid or expired OAuth state');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.oauthService.exchangeCodeForTokens(
|
||||
type,
|
||||
code,
|
||||
statePayload.integrationId,
|
||||
statePayload.userId,
|
||||
statePayload.workspaceId,
|
||||
);
|
||||
|
||||
const appUrl = this.environmentService.getAppUrl();
|
||||
return res.redirect(`${appUrl}/settings/integrations`, 302).send();
|
||||
} catch (err) {
|
||||
this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`);
|
||||
const appUrl = this.environmentService.getAppUrl();
|
||||
return res.redirect(`${appUrl}/settings/integrations?error=oauth_failed`, 302).send();
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('disconnect')
|
||||
async disconnect(
|
||||
@Body() dto: OAuthDisconnectDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
await this.connectionService.disconnect(
|
||||
dto.integrationId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
||||
import { IntegrationRepo } from '../repos/integration.repo';
|
||||
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
|
||||
import { encryptToken, decryptToken } from '../crypto/token-crypto';
|
||||
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
||||
import { OAuthConfig } from '../registry/integration-provider.interface';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
type OAuthTokenResponse = {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
integrationId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly registry: IntegrationRegistry,
|
||||
private readonly integrationRepo: IntegrationRepo,
|
||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
||||
) {}
|
||||
|
||||
async getAuthorizationUrl(
|
||||
integrationId: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<{ authorizationUrl: string }> {
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
if (!integration || integration.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException('Integration not found');
|
||||
}
|
||||
|
||||
const provider = this.registry.getProvider(integration.type);
|
||||
if (!provider || !provider.definition.oauth) {
|
||||
throw new BadRequestException('Integration does not support OAuth');
|
||||
}
|
||||
|
||||
const oauthConfig = provider.getOAuthConfig
|
||||
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
|
||||
: provider.definition.oauth;
|
||||
|
||||
const callbackUrl = this.buildCallbackUrl(integration.type);
|
||||
|
||||
const state = this.createSignedState({
|
||||
integrationId,
|
||||
userId,
|
||||
workspaceId,
|
||||
exp: Date.now() + 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.getClientId(integration.type),
|
||||
redirect_uri: callbackUrl,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
|
||||
const scope = oauthConfig.scopes
|
||||
.map((s) => encodeURIComponent(s))
|
||||
.join('%20');
|
||||
|
||||
return {
|
||||
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
|
||||
};
|
||||
}
|
||||
|
||||
verifySignedState(state: string): OAuthStatePayload | null {
|
||||
const dotIndex = state.lastIndexOf('.');
|
||||
if (dotIndex === -1) return null;
|
||||
|
||||
const data = state.substring(0, dotIndex);
|
||||
const signature = state.substring(dotIndex + 1);
|
||||
|
||||
const secret = this.environmentService.getAppSecret();
|
||||
const expected = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(data)
|
||||
.digest('base64url');
|
||||
|
||||
if (signature !== expected) return null;
|
||||
|
||||
try {
|
||||
const payload: OAuthStatePayload = JSON.parse(
|
||||
Buffer.from(data, 'base64url').toString(),
|
||||
);
|
||||
|
||||
if (payload.exp < Date.now()) return null;
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async exchangeCodeForTokens(
|
||||
type: string,
|
||||
code: string,
|
||||
integrationId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<IntegrationConnection> {
|
||||
const provider = this.registry.getProvider(type);
|
||||
if (!provider || !provider.definition.oauth) {
|
||||
throw new BadRequestException('Integration does not support OAuth');
|
||||
}
|
||||
|
||||
const integration = await this.integrationRepo.findById(integrationId);
|
||||
const settings = (integration?.settings as Record<string, any>) ?? {};
|
||||
|
||||
const oauthConfig = provider.getOAuthConfig
|
||||
? provider.getOAuthConfig(settings)
|
||||
: provider.definition.oauth;
|
||||
|
||||
const tokenResponse = await this.requestTokens(
|
||||
oauthConfig,
|
||||
type,
|
||||
code,
|
||||
);
|
||||
|
||||
const appSecret = this.environmentService.getAppSecret();
|
||||
const encryptedAccessToken = encryptToken(
|
||||
tokenResponse.access_token,
|
||||
appSecret,
|
||||
);
|
||||
const encryptedRefreshToken = tokenResponse.refresh_token
|
||||
? encryptToken(tokenResponse.refresh_token, appSecret)
|
||||
: null;
|
||||
|
||||
const tokenExpiresAt = tokenResponse.expires_in
|
||||
? new Date(Date.now() + tokenResponse.expires_in * 1000)
|
||||
: null;
|
||||
|
||||
const connection = await this.connectionRepo.upsert({
|
||||
integrationId,
|
||||
userId,
|
||||
workspaceId,
|
||||
accessToken: encryptedAccessToken,
|
||||
refreshToken: encryptedRefreshToken,
|
||||
tokenExpiresAt,
|
||||
scopes: tokenResponse.scope ?? null,
|
||||
});
|
||||
|
||||
if (provider.onConnected) {
|
||||
await provider.onConnected({
|
||||
accessToken: tokenResponse.access_token,
|
||||
refreshToken: tokenResponse.refresh_token,
|
||||
providerUserId: '',
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
async getValidAccessToken(
|
||||
connection: IntegrationConnection,
|
||||
): Promise<string> {
|
||||
const appSecret = this.environmentService.getAppSecret();
|
||||
const accessToken = decryptToken(connection.accessToken, appSecret);
|
||||
|
||||
const needsRefresh =
|
||||
connection.tokenExpiresAt &&
|
||||
connection.refreshToken &&
|
||||
new Date(connection.tokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000;
|
||||
|
||||
if (!needsRefresh) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
return this.refreshAccessToken(connection);
|
||||
}
|
||||
|
||||
private async refreshAccessToken(
|
||||
connection: IntegrationConnection,
|
||||
): Promise<string> {
|
||||
const appSecret = this.environmentService.getAppSecret();
|
||||
const refreshToken = decryptToken(connection.refreshToken, appSecret);
|
||||
|
||||
const integration = await this.integrationRepo.findById(
|
||||
connection.integrationId,
|
||||
);
|
||||
if (!integration) {
|
||||
throw new NotFoundException('Integration not found');
|
||||
}
|
||||
|
||||
const provider = this.registry.getProvider(integration.type);
|
||||
if (!provider || !provider.definition.oauth) {
|
||||
throw new BadRequestException('Integration does not support OAuth');
|
||||
}
|
||||
|
||||
const oauthConfig = provider.getOAuthConfig
|
||||
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
|
||||
: provider.definition.oauth;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.getClientId(integration.type),
|
||||
client_secret: this.getClientSecret(integration.type),
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(oauthConfig.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error(
|
||||
`Token refresh failed for ${integration.type}: ${response.status}`,
|
||||
);
|
||||
throw new BadRequestException('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: OAuthTokenResponse = await response.json();
|
||||
const encryptedAccessToken = encryptToken(data.access_token, appSecret);
|
||||
const encryptedRefreshToken = data.refresh_token
|
||||
? encryptToken(data.refresh_token, appSecret)
|
||||
: connection.refreshToken;
|
||||
const tokenExpiresAt = data.expires_in
|
||||
? new Date(Date.now() + data.expires_in * 1000)
|
||||
: null;
|
||||
|
||||
await this.connectionRepo.update(connection.id, {
|
||||
accessToken: encryptedAccessToken,
|
||||
refreshToken: encryptedRefreshToken,
|
||||
tokenExpiresAt,
|
||||
});
|
||||
|
||||
return data.access_token;
|
||||
} catch (err) {
|
||||
this.logger.error(`Token refresh error: ${(err as Error).message}`);
|
||||
throw new BadRequestException('Failed to refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
private async requestTokens(
|
||||
oauthConfig: OAuthConfig,
|
||||
type: string,
|
||||
code: string,
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.getClientId(type),
|
||||
client_secret: this.getClientSecret(type),
|
||||
code,
|
||||
redirect_uri: this.buildCallbackUrl(type),
|
||||
});
|
||||
|
||||
const response = await fetch(oauthConfig.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
this.logger.error(`Token exchange failed for ${type}: ${response.status} ${body}`);
|
||||
throw new BadRequestException('OAuth token exchange failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
buildCallbackUrl(type: string): string {
|
||||
const appUrl = this.environmentService.getAppUrl();
|
||||
return `${appUrl}/api/integrations/oauth/${type}/callback`;
|
||||
}
|
||||
|
||||
private createSignedState(payload: OAuthStatePayload): string {
|
||||
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const secret = this.environmentService.getAppSecret();
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(data)
|
||||
.digest('base64url');
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
private getClientId(type: string): string {
|
||||
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_ID`;
|
||||
const value = process.env[envKey];
|
||||
if (!value) {
|
||||
throw new BadRequestException(
|
||||
`Missing environment variable: ${envKey}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private getClientSecret(type: string): string {
|
||||
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_SECRET`;
|
||||
const value = process.env[envKey];
|
||||
if (!value) {
|
||||
throw new BadRequestException(
|
||||
`Missing environment variable: ${envKey}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions' | 'webhooks';
|
||||
|
||||
export type OAuthConfig = {
|
||||
authUrl: string;
|
||||
tokenUrl: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type UnfurlPattern = {
|
||||
regex: RegExp;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type UnfurlResult = {
|
||||
title: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
provider: string;
|
||||
providerIcon?: string;
|
||||
status?: string;
|
||||
statusColor?: string;
|
||||
author?: string;
|
||||
authorAvatarUrl?: string;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type IntegrationDefinition = {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
capabilities: IntegrationCapability[];
|
||||
oauth?: OAuthConfig;
|
||||
unfurlPatterns?: UnfurlPattern[];
|
||||
};
|
||||
|
||||
export type ConnectedEvent = {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
providerUserId: string;
|
||||
metadata: Record<string, any>;
|
||||
};
|
||||
|
||||
export type HandleEventOpts = {
|
||||
eventName: string;
|
||||
payload: Record<string, any>;
|
||||
integration: {
|
||||
id: string;
|
||||
type: string;
|
||||
settings: Record<string, any> | null;
|
||||
};
|
||||
connection?: {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UnfurlOpts = {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
match: RegExpMatchArray;
|
||||
patternType: string;
|
||||
};
|
||||
|
||||
export abstract class IntegrationProvider {
|
||||
abstract definition: IntegrationDefinition;
|
||||
|
||||
getOAuthConfig?(
|
||||
workspaceSettings: Record<string, any>,
|
||||
): OAuthConfig;
|
||||
|
||||
getUnfurlPatterns?(
|
||||
workspaceSettings: Record<string, any>,
|
||||
): UnfurlPattern[];
|
||||
|
||||
onConnected?(opts: ConnectedEvent): Promise<void>;
|
||||
|
||||
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
|
||||
|
||||
handleEvent?(opts: HandleEventOpts): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
IntegrationDefinition,
|
||||
IntegrationProvider,
|
||||
} from './integration-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationRegistry {
|
||||
private providers = new Map<string, IntegrationProvider>();
|
||||
|
||||
register(provider: IntegrationProvider): void {
|
||||
this.providers.set(provider.definition.type, provider);
|
||||
}
|
||||
|
||||
getProvider(type: string): IntegrationProvider | undefined {
|
||||
return this.providers.get(type);
|
||||
}
|
||||
|
||||
getAllProviders(): IntegrationProvider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
getAvailableIntegrations(): IntegrationDefinition[] {
|
||||
return this.getAllProviders().map((p) => p.definition);
|
||||
}
|
||||
|
||||
findUnfurlProvider(
|
||||
url: string,
|
||||
): {
|
||||
provider: IntegrationProvider;
|
||||
match: RegExpMatchArray;
|
||||
patternType: string;
|
||||
} | null {
|
||||
for (const provider of this.providers.values()) {
|
||||
if (!provider.definition.unfurlPatterns) continue;
|
||||
for (const pattern of provider.definition.unfurlPatterns) {
|
||||
const match = url.match(pattern.regex);
|
||||
if (match) {
|
||||
return { provider, match, patternType: pattern.type };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IntegrationConnection,
|
||||
InsertableIntegrationConnection,
|
||||
UpdatableIntegrationConnection,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationConnectionRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
connectionId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationConnection | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationConnections')
|
||||
.selectAll()
|
||||
.where('id', '=', connectionId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByIntegrationAndUser(
|
||||
integrationId: string,
|
||||
userId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationConnection | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationConnections')
|
||||
.selectAll()
|
||||
.where('integrationId', '=', integrationId)
|
||||
.where('userId', '=', userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByWorkspaceTypeAndUser(
|
||||
workspaceId: string,
|
||||
integrationType: string,
|
||||
userId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationConnection | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationConnections')
|
||||
.innerJoin(
|
||||
'integrations',
|
||||
'integrations.id',
|
||||
'integrationConnections.integrationId',
|
||||
)
|
||||
.selectAll('integrationConnections')
|
||||
.where('integrations.workspaceId', '=', workspaceId)
|
||||
.where('integrations.type', '=', integrationType)
|
||||
.where('integrations.deletedAt', 'is', null)
|
||||
.where('integrationConnections.userId', '=', userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByIntegration(
|
||||
integrationId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationConnection[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationConnections')
|
||||
.selectAll()
|
||||
.where('integrationId', '=', integrationId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsert(
|
||||
connection: InsertableIntegrationConnection,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationConnection> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('integrationConnections')
|
||||
.values(connection)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['integrationId', 'userId']).doUpdateSet({
|
||||
accessToken: connection.accessToken,
|
||||
refreshToken: connection.refreshToken,
|
||||
tokenExpiresAt: connection.tokenExpiresAt,
|
||||
scopes: connection.scopes,
|
||||
providerUserId: connection.providerUserId,
|
||||
metadata: connection.metadata,
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async update(
|
||||
connectionId: string,
|
||||
data: UpdatableIntegrationConnection,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationConnection> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('integrationConnections')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', connectionId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async deleteByIntegrationAndUser(
|
||||
integrationId: string,
|
||||
userId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('integrationConnections')
|
||||
.where('integrationId', '=', integrationId)
|
||||
.where('userId', '=', userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByIntegration(
|
||||
integrationId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('integrationConnections')
|
||||
.where('integrationId', '=', integrationId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
IntegrationWebhook,
|
||||
InsertableIntegrationWebhook,
|
||||
UpdatableIntegrationWebhook,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationWebhookRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
webhookId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationWebhook | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationWebhooks')
|
||||
.selectAll()
|
||||
.where('id', '=', webhookId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByIntegration(
|
||||
integrationId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationWebhook[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationWebhooks')
|
||||
.selectAll()
|
||||
.where('integrationId', '=', integrationId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findEnabledByEvent(
|
||||
workspaceId: string,
|
||||
eventType: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationWebhook[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrationWebhooks')
|
||||
.selectAll()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('eventType', '=', eventType)
|
||||
.where('isEnabled', '=', true)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async insert(
|
||||
webhook: InsertableIntegrationWebhook,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationWebhook> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('integrationWebhooks')
|
||||
.values(webhook)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async update(
|
||||
webhookId: string,
|
||||
data: UpdatableIntegrationWebhook,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<IntegrationWebhook> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('integrationWebhooks')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', webhookId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async delete(
|
||||
webhookId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('integrationWebhooks')
|
||||
.where('id', '=', webhookId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByIntegration(
|
||||
integrationId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.deleteFrom('integrationWebhooks')
|
||||
.where('integrationId', '=', integrationId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import {
|
||||
Integration,
|
||||
InsertableIntegration,
|
||||
UpdatableIntegration,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
|
||||
@Injectable()
|
||||
export class IntegrationRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async findById(
|
||||
integrationId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.where('id', '=', integrationId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByWorkspaceAndType(
|
||||
workspaceId: string,
|
||||
type: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('type', '=', type)
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findEnabledByWorkspace(
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('isEnabled', '=', true)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findAllByWorkspace(
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration[]> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('integrations')
|
||||
.selectAll()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async insert(
|
||||
integration: InsertableIntegration,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('integrations')
|
||||
.values(integration)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async insertOrRestore(
|
||||
integration: InsertableIntegration,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('integrations')
|
||||
.values(integration)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['type', 'workspaceId']).doUpdateSet({
|
||||
deletedAt: null,
|
||||
isEnabled: true,
|
||||
installedById: integration.installedById,
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async update(
|
||||
integrationId: string,
|
||||
data: UpdatableIntegration,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<Integration> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('integrations')
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where('id', '=', integrationId)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async softDelete(
|
||||
integrationId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('integrations')
|
||||
.set({ deletedAt: new Date() })
|
||||
.where('id', '=', integrationId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { UnfurlService } from './unfurl.service';
|
||||
import { UnfurlDto } from '../dto/integration.dto';
|
||||
|
||||
@Controller('integrations')
|
||||
export class UnfurlController {
|
||||
constructor(private readonly unfurlService: UnfurlService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('unfurl')
|
||||
async unfurl(
|
||||
@Body() dto: UnfurlDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const result = await this.unfurlService.unfurl(
|
||||
dto.url,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
return { data: result };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
||||
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
|
||||
import { IntegrationRepo } from '../repos/integration.repo';
|
||||
import { OAuthService } from '../oauth/oauth.service';
|
||||
import {
|
||||
UnfurlResult,
|
||||
IntegrationProvider,
|
||||
} from '../registry/integration-provider.interface';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const UNFURL_CACHE_TTL = 300; // 5 minutes
|
||||
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
||||
|
||||
@Injectable()
|
||||
export class UnfurlService {
|
||||
private readonly logger = new Logger(UnfurlService.name);
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(
|
||||
private readonly registry: IntegrationRegistry,
|
||||
private readonly integrationRepo: IntegrationRepo,
|
||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
||||
private readonly oauthService: OAuthService,
|
||||
private readonly redisService: RedisService,
|
||||
) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
async unfurl(
|
||||
url: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<UnfurlResult | null> {
|
||||
const cacheKey = this.buildCacheKey(workspaceId, url);
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const resolved = await this.resolveProvider(url, workspaceId);
|
||||
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { provider, match, patternType, integration } = resolved;
|
||||
|
||||
if (!provider.unfurl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connection = await this.connectionRepo.findByIntegrationAndUser(
|
||||
integration.id,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken =
|
||||
await this.oauthService.getValidAccessToken(connection);
|
||||
|
||||
const unfurlResult = await provider.unfurl({
|
||||
url,
|
||||
accessToken,
|
||||
match,
|
||||
patternType,
|
||||
});
|
||||
|
||||
await this.redis.set(
|
||||
cacheKey,
|
||||
JSON.stringify(unfurlResult),
|
||||
'EX',
|
||||
UNFURL_CACHE_TTL,
|
||||
);
|
||||
|
||||
return unfurlResult;
|
||||
} catch (err) {
|
||||
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveProvider(
|
||||
url: string,
|
||||
workspaceId: string,
|
||||
): Promise<{
|
||||
provider: IntegrationProvider;
|
||||
match: RegExpMatchArray;
|
||||
patternType: string;
|
||||
integration: { id: string; isEnabled: boolean; type: string };
|
||||
} | null> {
|
||||
const staticResult = this.registry.findUnfurlProvider(url);
|
||||
if (staticResult) {
|
||||
const integration = await this.integrationRepo.findByWorkspaceAndType(
|
||||
workspaceId,
|
||||
staticResult.provider.definition.type,
|
||||
);
|
||||
if (integration && integration.isEnabled) {
|
||||
return { ...staticResult, integration };
|
||||
}
|
||||
}
|
||||
|
||||
const integrations =
|
||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
||||
|
||||
for (const integration of integrations) {
|
||||
const provider = this.registry.getProvider(integration.type);
|
||||
if (!provider?.getUnfurlPatterns || !provider.unfurl) continue;
|
||||
|
||||
const settings = (integration.settings as Record<string, any>) ?? {};
|
||||
const patterns = provider.getUnfurlPatterns(settings);
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern.regex);
|
||||
if (match) {
|
||||
return { provider, match, patternType: pattern.type, integration };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildCacheKey(workspaceId: string, url: string): string {
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(url)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return `${UNFURL_CACHE_PREFIX}${workspaceId}:${hash}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('integrations')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('type', 'text', (col) => col.notNull())
|
||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('settings', 'jsonb')
|
||||
.addColumn('installed_by_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('set null'),
|
||||
)
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('deleted_at', 'timestamptz')
|
||||
.addUniqueConstraint('uq_integrations_workspace_type', [
|
||||
'workspace_id',
|
||||
'type',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('integration_connections')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('integration_id', 'uuid', (col) =>
|
||||
col.references('integrations.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.references('users.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('provider_user_id', 'text')
|
||||
.addColumn('access_token', 'text', (col) => col.notNull())
|
||||
.addColumn('refresh_token', 'text')
|
||||
.addColumn('token_expires_at', 'timestamptz')
|
||||
.addColumn('scopes', 'text')
|
||||
.addColumn('metadata', 'jsonb')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addUniqueConstraint('uq_integration_connections_integration_user', [
|
||||
'integration_id',
|
||||
'user_id',
|
||||
])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('integration_webhooks')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('integration_id', 'uuid', (col) =>
|
||||
col.references('integrations.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
||||
)
|
||||
.addColumn('event_type', 'text', (col) => col.notNull())
|
||||
.addColumn('webhook_url', 'text')
|
||||
.addColumn('secret', 'text')
|
||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_integration_webhooks_integration_event')
|
||||
.on('integration_webhooks')
|
||||
.columns(['integration_id', 'event_type'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('integration_webhooks').execute();
|
||||
await db.schema.dropTable('integration_connections').execute();
|
||||
await db.schema.dropTable('integrations').execute();
|
||||
}
|
||||
+42
@@ -390,6 +390,45 @@ export interface Watchers {
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface Integrations {
|
||||
id: Generated<string>;
|
||||
workspaceId: string;
|
||||
type: string;
|
||||
isEnabled: Generated<boolean>;
|
||||
settings: Json | null;
|
||||
installedById: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
}
|
||||
|
||||
export interface IntegrationConnections {
|
||||
id: Generated<string>;
|
||||
integrationId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
providerUserId: string | null;
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
tokenExpiresAt: Timestamp | null;
|
||||
scopes: string | null;
|
||||
metadata: Json | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface IntegrationWebhooks {
|
||||
id: Generated<string>;
|
||||
integrationId: string;
|
||||
workspaceId: string;
|
||||
eventType: string;
|
||||
webhookUrl: string | null;
|
||||
secret: string | null;
|
||||
isEnabled: Generated<boolean>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
@@ -401,6 +440,9 @@ export interface DB {
|
||||
fileTasks: FileTasks;
|
||||
groups: Groups;
|
||||
groupUsers: GroupUsers;
|
||||
integrationConnections: IntegrationConnections;
|
||||
integrationWebhooks: IntegrationWebhooks;
|
||||
integrations: Integrations;
|
||||
notifications: Notifications;
|
||||
pageHistory: PageHistory;
|
||||
pages: Pages;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { DB } from '@docmost/db/types/db';
|
||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||
import {
|
||||
Integrations,
|
||||
IntegrationConnections,
|
||||
IntegrationWebhooks,
|
||||
} from '@docmost/db/types/db';
|
||||
|
||||
export interface DbInterface extends DB {
|
||||
pageEmbeddings: PageEmbeddings;
|
||||
integrations: Integrations;
|
||||
integrationConnections: IntegrationConnections;
|
||||
integrationWebhooks: IntegrationWebhooks;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
Attachments,
|
||||
Comments,
|
||||
Groups,
|
||||
Integrations as _Integrations,
|
||||
IntegrationConnections as _IntegrationConnections,
|
||||
IntegrationWebhooks as _IntegrationWebhooks,
|
||||
Notifications,
|
||||
Pages,
|
||||
Spaces,
|
||||
@@ -143,3 +146,23 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
||||
export type Watcher = Selectable<Watchers>;
|
||||
export type InsertableWatcher = Insertable<Watchers>;
|
||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||
|
||||
// Integration
|
||||
export type Integration = Selectable<_Integrations>;
|
||||
export type InsertableIntegration = Insertable<_Integrations>;
|
||||
export type UpdatableIntegration = Updateable<Omit<_Integrations, 'id'>>;
|
||||
|
||||
// Integration Connection
|
||||
export type IntegrationConnection = Selectable<_IntegrationConnections>;
|
||||
export type InsertableIntegrationConnection =
|
||||
Insertable<_IntegrationConnections>;
|
||||
export type UpdatableIntegrationConnection = Updateable<
|
||||
Omit<_IntegrationConnections, 'id'>
|
||||
>;
|
||||
|
||||
// Integration Webhook
|
||||
export type IntegrationWebhook = Selectable<_IntegrationWebhooks>;
|
||||
export type InsertableIntegrationWebhook = Insertable<_IntegrationWebhooks>;
|
||||
export type UpdatableIntegrationWebhook = Updateable<
|
||||
Omit<_IntegrationWebhooks, 'id'>
|
||||
>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 71b4323d1b...010a833e1a
@@ -8,6 +8,7 @@ export enum QueueName {
|
||||
AI_QUEUE = '{ai-queue}',
|
||||
HISTORY_QUEUE = '{history-queue}',
|
||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||
INTEGRATION_QUEUE = '{integration-queue}',
|
||||
}
|
||||
|
||||
export enum QueueJob {
|
||||
@@ -67,4 +68,6 @@ export enum QueueJob {
|
||||
COMMENT_NOTIFICATION = 'comment-notification',
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
|
||||
INTEGRATION_EVENT = 'integration-event',
|
||||
}
|
||||
|
||||
@@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.NOTIFICATION_QUEUE,
|
||||
}),
|
||||
BullModule.registerQueue({
|
||||
name: QueueName.INTEGRATION_QUEUE,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: { count: 50 },
|
||||
attempts: 3,
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [BullModule],
|
||||
providers: [GeneralQueueProcessor],
|
||||
|
||||
@@ -67,6 +67,7 @@ async function bootstrap() {
|
||||
'/api/sso/google',
|
||||
'/api/workspace/create',
|
||||
'/api/workspace/joined',
|
||||
'/api/integrations/oauth'
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
+2
-1
@@ -76,7 +76,8 @@
|
||||
"uuid": "^11.1.0",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"yjs": "^13.6.29"
|
||||
"yjs": "^13.6.29",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nx/js": "22.5.0",
|
||||
|
||||
@@ -25,3 +25,4 @@ export * from "./lib/heading/heading";
|
||||
export * from "./lib/unique-id";
|
||||
export * from "./lib/shared-storage";
|
||||
export * from "./lib/recreate-transform";
|
||||
export * from "./lib/integration-link";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export { IntegrationLink } from "./integration-link";
|
||||
export type {
|
||||
IntegrationLinkOptions,
|
||||
IntegrationLinkAttributes,
|
||||
} from "./integration-link";
|
||||
export {
|
||||
integrationLinkPatterns,
|
||||
matchIntegrationLink,
|
||||
} from "./integration-link-patterns";
|
||||
export type { IntegrationLinkPattern } from "./integration-link-patterns";
|
||||
@@ -0,0 +1,171 @@
|
||||
export type IntegrationLinkPattern = {
|
||||
provider: string;
|
||||
regex: RegExp;
|
||||
};
|
||||
|
||||
export const integrationLinkPatterns: IntegrationLinkPattern[] = [
|
||||
// GitHub PR commit (must be before generic PR pattern)
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/,
|
||||
},
|
||||
// GitHub PR (with optional /checks, /commits, /files sub-pages)
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/,
|
||||
},
|
||||
// GitHub issue
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/,
|
||||
},
|
||||
// GitHub commit
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/commits?\/([a-f0-9]+)/,
|
||||
},
|
||||
// GitHub file/blob
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/,
|
||||
},
|
||||
// GitHub pulls list
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pulls(?:\/.*)?(?:\?.*)?$/,
|
||||
},
|
||||
// GitHub releases list
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/releases(?:\/.*)?(?:\?.*)?$/,
|
||||
},
|
||||
// GitHub issues list
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/,
|
||||
},
|
||||
// GitHub repo
|
||||
{
|
||||
provider: "github",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/?$/,
|
||||
},
|
||||
// GitLab commit in MR diff (must be before generic MR pattern)
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/,
|
||||
},
|
||||
// GitLab merge request
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/,
|
||||
},
|
||||
// GitLab issue
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/,
|
||||
},
|
||||
// GitLab commit
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/,
|
||||
},
|
||||
// GitLab issues list
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/,
|
||||
},
|
||||
// GitLab merge requests list
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/,
|
||||
},
|
||||
// GitLab project
|
||||
{
|
||||
provider: "gitlab",
|
||||
regex:
|
||||
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_]+)\/?$/,
|
||||
},
|
||||
// Google Docs
|
||||
{
|
||||
provider: "google_docs",
|
||||
regex: /^https?:\/\/docs\.google\.com\/document\/d\/([\w-]+)/,
|
||||
},
|
||||
// Google Sheets
|
||||
{
|
||||
provider: "google_docs",
|
||||
regex: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/,
|
||||
},
|
||||
// Google Slides
|
||||
{
|
||||
provider: "google_docs",
|
||||
regex: /^https?:\/\/docs\.google\.com\/presentation\/d\/([\w-]+)/,
|
||||
},
|
||||
// Google Forms
|
||||
{
|
||||
provider: "google_docs",
|
||||
regex: /^https?:\/\/docs\.google\.com\/forms\/d\/([\w-]+)/,
|
||||
},
|
||||
// Google Drive file
|
||||
{
|
||||
provider: "google_docs",
|
||||
regex: /^https?:\/\/drive\.google\.com\/file\/d\/([\w-]+)/,
|
||||
},
|
||||
// Figma file (design, file, proto, board)
|
||||
{
|
||||
provider: "figma",
|
||||
regex:
|
||||
/^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/,
|
||||
},
|
||||
// Jira (cloud + server): /browse/KEY-123
|
||||
{
|
||||
provider: "jira",
|
||||
regex: /^https?:\/\/[^\/]+\/browse\/([A-Z][A-Z0-9]+-\d+)/,
|
||||
},
|
||||
// Linear issue: /team/issue/KEY-123(/:title-slug)?
|
||||
{
|
||||
provider: "linear",
|
||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)/,
|
||||
},
|
||||
// Linear project: /team/project/:slug(/:tab)?
|
||||
{
|
||||
provider: "linear",
|
||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/project\/([^\/]+)/,
|
||||
},
|
||||
// Linear initiative: /team/initiative/:slug(/:tab)?
|
||||
{
|
||||
provider: "linear",
|
||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/initiative\/([^\/]+)/,
|
||||
},
|
||||
// Linear view: /team/view/:id(/:tab)?
|
||||
{
|
||||
provider: "linear",
|
||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/view\/([^\/]+)/,
|
||||
},
|
||||
];
|
||||
|
||||
export function matchIntegrationLink(
|
||||
url: string,
|
||||
): { provider: string; match: RegExpMatchArray } | null {
|
||||
for (const pattern of integrationLinkPatterns) {
|
||||
const match = url.match(pattern.regex);
|
||||
if (match) {
|
||||
return { provider: pattern.provider, match };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { sanitizeUrl } from "../utils";
|
||||
|
||||
export interface IntegrationLinkOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface IntegrationLinkAttributes {
|
||||
url: string;
|
||||
provider: string;
|
||||
unfurlData: Record<string, any> | null;
|
||||
status: "pending" | "loaded" | "error";
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
integrationLink: {
|
||||
setIntegrationLink: (
|
||||
attributes: Partial<IntegrationLinkAttributes>,
|
||||
) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const IntegrationLink = Node.create<IntegrationLinkOptions>({
|
||||
name: "integrationLink",
|
||||
inline: false,
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
draggable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
url: {
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const url = element.getAttribute("data-url");
|
||||
return sanitizeUrl(url);
|
||||
},
|
||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
||||
"data-url": sanitizeUrl(attributes.url),
|
||||
}),
|
||||
},
|
||||
provider: {
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
||||
"data-provider": attributes.provider,
|
||||
}),
|
||||
},
|
||||
unfurlData: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const data = element.getAttribute("data-unfurl");
|
||||
if (!data) return null;
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
||||
"data-unfurl": attributes.unfurlData
|
||||
? JSON.stringify(attributes.unfurlData)
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
default: "pending",
|
||||
parseHTML: (element) => element.getAttribute("data-status") ?? "pending",
|
||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
||||
"data-status": attributes.status,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const url = HTMLAttributes["data-url"];
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setIntegrationLink:
|
||||
(attrs) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
...attrs,
|
||||
url: sanitizeUrl(attrs.url),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
Generated
+3
@@ -211,6 +211,9 @@ importers:
|
||||
yjs:
|
||||
specifier: ^13.6.29
|
||||
version: 13.6.29
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@nx/js':
|
||||
specifier: 22.5.0
|
||||
|
||||
Reference in New Issue
Block a user