Compare commits

...

2 Commits

Author SHA1 Message Date
Philipinho 81c6fb0d56 fix 2026-02-22 22:32:40 +00:00
Philipinho aeb30ad096 feat: integrations 2026-02-22 20:14:33 +00:00
49 changed files with 2923 additions and 4 deletions
+2
View File
@@ -37,6 +37,7 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
import UserApiKeys from "@/ee/api-key/pages/user-api-keys"; import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import Integrations from "@/features/integration/pages/integrations.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -102,6 +103,7 @@ export default function App() {
<Route path={"sharing"} element={<Shares />} /> <Route path={"sharing"} element={<Shares />} />
<Route path={"security"} element={<Security />} /> <Route path={"security"} element={<Security />} />
<Route path={"ai"} element={<AiSettings />} /> <Route path={"ai"} element={<AiSettings />} />
<Route path={"integrations"} element={<Integrations />} />
{!isCloud() && <Route path={"license"} element={<License />} />} {!isCloud() && <Route path={"license"} element={<License />} />}
{isCloud() && <Route path={"billing"} element={<Billing />} />} {isCloud() && <Route path={"billing"} element={<Billing />} />}
</Route> </Route>
@@ -13,6 +13,7 @@ import {
IconKey, IconKey,
IconWorld, IconWorld,
IconSparkles, IconSparkles,
IconPlug,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import classes from "./settings.module.css"; import classes from "./settings.module.css";
@@ -116,6 +117,12 @@ const groupedData: DataGroup[] = [
path: "/settings/ai", path: "/settings/ai",
isAdmin: true, isAdmin: true,
}, },
{
label: "Integrations",
icon: IconPlug,
path: "/settings/integrations",
isAdmin: true,
},
], ],
}, },
{ {
@@ -34,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
withArrow withArrow
> >
<Popover.Target> <Popover.Target>
<Tooltip label={t("Add link")} withArrow> <Tooltip label={t("Add link")} withArrow withinPortal={false}>
<ActionIcon <ActionIcon
variant="default" variant="default"
size="lg" 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 { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts"; import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { matchIntegrationLink } from "@docmost/editor-ext";
export const handlePaste = ( export const handlePaste = (
editor: Editor, editor: Editor,
@@ -13,6 +14,21 @@ export const handlePaste = (
) => { ) => {
const clipboardData = event.clipboardData.getData("text/plain"); 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)) { if (INTERNAL_LINK_REGEX.test(clipboardData)) {
// we have to do this validation here to allow the default link extension to takeover if needs be // we have to do this validation here to allow the default link extension to takeover if needs be
event.preventDefault(); event.preventDefault();
@@ -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);
}
@@ -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)"> <Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
<Flex align="center"> <Flex align="center">
<Tooltip label={url}> <Tooltip label={url} withArrow withinPortal={false}>
<Anchor <Anchor
href={url} href={url}
target="_blank" target="_blank"
@@ -43,6 +43,7 @@ import {
Highlight, Highlight,
UniqueID, UniqueID,
SharedStorage, SharedStorage,
IntegrationLink,
} from "@docmost/editor-ext"; } from "@docmost/editor-ext";
import { import {
randomElement, randomElement,
@@ -60,6 +61,7 @@ import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx"; import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-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 { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext"; import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell"; import powershell from "highlight.js/lib/languages/powershell";
@@ -231,6 +233,9 @@ export const mainExtensions = [
Subpages.configure({ Subpages.configure({
view: SubpagesView, view: SubpagesView,
}), }),
IntegrationLink.configure({
view: IntegrationLinkView,
}),
MarkdownClipboard.configure({ MarkdownClipboard.configure({
transformPastedText: true, 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>;
};
+1
View File
@@ -25,6 +25,7 @@ const APP_ROUTE = {
SPACES: "/settings/spaces", SPACES: "/settings/spaces",
BILLING: "/settings/billing", BILLING: "/settings/billing",
SECURITY: "/settings/security", SECURITY: "/settings/security",
INTEGRATIONS: "/settings/integrations",
}, },
}, },
}; };
+3
View File
@@ -18,6 +18,7 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module'; import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module'; import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module'; import { WatcherModule } from './watcher/watcher.module';
import { IntegrationModule } from './integration/integration.module';
@Module({ @Module({
imports: [ imports: [
@@ -34,6 +35,7 @@ import { WatcherModule } from './watcher/watcher.module';
ShareModule, ShareModule,
NotificationModule, NotificationModule,
WatcherModule, WatcherModule,
IntegrationModule,
], ],
}) })
export class CoreModule implements NestModule { export class CoreModule implements NestModule {
@@ -45,6 +47,7 @@ export class CoreModule implements NestModule {
{ path: 'health', method: RequestMethod.GET }, { path: 'health', method: RequestMethod.GET },
{ path: 'health/live', method: RequestMethod.GET }, { path: 'health/live', method: RequestMethod.GET },
{ path: 'billing/stripe/webhook', method: RequestMethod.POST }, { path: 'billing/stripe/webhook', method: RequestMethod.POST },
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
) )
.forRoutes('*'); .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
View File
@@ -390,6 +390,45 @@ export interface Watchers {
createdAt: Generated<Timestamp>; 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 { export interface DB {
apiKeys: ApiKeys; apiKeys: ApiKeys;
attachments: Attachments; attachments: Attachments;
@@ -401,6 +440,9 @@ export interface DB {
fileTasks: FileTasks; fileTasks: FileTasks;
groups: Groups; groups: Groups;
groupUsers: GroupUsers; groupUsers: GroupUsers;
integrationConnections: IntegrationConnections;
integrationWebhooks: IntegrationWebhooks;
integrations: Integrations;
notifications: Notifications; notifications: Notifications;
pageHistory: PageHistory; pageHistory: PageHistory;
pages: Pages; pages: Pages;
@@ -1,6 +1,14 @@
import { DB } from '@docmost/db/types/db'; import { DB } from '@docmost/db/types/db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
import {
Integrations,
IntegrationConnections,
IntegrationWebhooks,
} from '@docmost/db/types/db';
export interface DbInterface extends DB { export interface DbInterface extends DB {
pageEmbeddings: PageEmbeddings; pageEmbeddings: PageEmbeddings;
integrations: Integrations;
integrationConnections: IntegrationConnections;
integrationWebhooks: IntegrationWebhooks;
} }
@@ -3,6 +3,9 @@ import {
Attachments, Attachments,
Comments, Comments,
Groups, Groups,
Integrations as _Integrations,
IntegrationConnections as _IntegrationConnections,
IntegrationWebhooks as _IntegrationWebhooks,
Notifications, Notifications,
Pages, Pages,
Spaces, Spaces,
@@ -143,3 +146,23 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
export type Watcher = Selectable<Watchers>; export type Watcher = Selectable<Watchers>;
export type InsertableWatcher = Insertable<Watchers>; export type InsertableWatcher = Insertable<Watchers>;
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>; 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'>
>;
@@ -8,6 +8,7 @@ export enum QueueName {
AI_QUEUE = '{ai-queue}', AI_QUEUE = '{ai-queue}',
HISTORY_QUEUE = '{history-queue}', HISTORY_QUEUE = '{history-queue}',
NOTIFICATION_QUEUE = '{notification-queue}', NOTIFICATION_QUEUE = '{notification-queue}',
INTEGRATION_QUEUE = '{integration-queue}',
} }
export enum QueueJob { export enum QueueJob {
@@ -67,4 +68,6 @@ export enum QueueJob {
COMMENT_NOTIFICATION = 'comment-notification', COMMENT_NOTIFICATION = 'comment-notification',
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification', COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
PAGE_MENTION_NOTIFICATION = 'page-mention-notification', PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
INTEGRATION_EVENT = 'integration-event',
} }
@@ -84,6 +84,14 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
BullModule.registerQueue({ BullModule.registerQueue({
name: QueueName.NOTIFICATION_QUEUE, name: QueueName.NOTIFICATION_QUEUE,
}), }),
BullModule.registerQueue({
name: QueueName.INTEGRATION_QUEUE,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: { count: 50 },
attempts: 3,
},
}),
], ],
exports: [BullModule], exports: [BullModule],
providers: [GeneralQueueProcessor], providers: [GeneralQueueProcessor],
+1
View File
@@ -67,6 +67,7 @@ async function bootstrap() {
'/api/sso/google', '/api/sso/google',
'/api/workspace/create', '/api/workspace/create',
'/api/workspace/joined', '/api/workspace/joined',
'/api/integrations/oauth'
]; ];
if ( if (
+2 -1
View File
@@ -76,7 +76,8 @@
"uuid": "^11.1.0", "uuid": "^11.1.0",
"y-indexeddb": "^9.0.12", "y-indexeddb": "^9.0.12",
"y-prosemirror": "1.3.7", "y-prosemirror": "1.3.7",
"yjs": "^13.6.29" "yjs": "^13.6.29",
"zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@nx/js": "22.5.0", "@nx/js": "22.5.0",
+1
View File
@@ -25,3 +25,4 @@ export * from "./lib/heading/heading";
export * from "./lib/unique-id"; export * from "./lib/unique-id";
export * from "./lib/shared-storage"; export * from "./lib/shared-storage";
export * from "./lib/recreate-transform"; 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);
},
});
+3
View File
@@ -211,6 +211,9 @@ importers:
yjs: yjs:
specifier: ^13.6.29 specifier: ^13.6.29
version: 13.6.29 version: 13.6.29
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies: devDependencies:
'@nx/js': '@nx/js':
specifier: 22.5.0 specifier: 22.5.0