From aeb30ad096dd37178af7d25670e562d4b7f11259 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:14:33 +0000 Subject: [PATCH] feat: integrations --- apps/client/src/App.tsx | 2 + .../components/settings/settings-sidebar.tsx | 7 + .../components/bubble-menu/link-selector.tsx | 2 +- .../common/editor-paste-handler.tsx | 16 + .../integration-link-view.module.css | 10 + .../integration-link-view.tsx | 144 ++++++++ .../editor/components/link/link-preview.tsx | 2 +- .../features/editor/extensions/extensions.ts | 5 + .../components/integration-card.tsx | 104 ++++++ .../components/integration-settings-modal.tsx | 91 +++++ .../integration/pages/integrations.tsx | 111 ++++++ .../integration/queries/integration-query.ts | 109 ++++++ .../services/integration-service.ts | 79 +++++ .../integration/types/integration.types.ts | 38 +++ apps/client/src/lib/app-route.ts | 1 + apps/server/src/core/core.module.ts | 3 + apps/server/src/core/integration/constants.ts | 7 + .../core/integration/crypto/token-crypto.ts | 36 ++ .../dto/integration-settings.schema.ts | 68 ++++ .../core/integration/dto/integration.dto.ts | 51 +++ .../integration-connection.service.ts | 68 ++++ .../integration/integration.controller.ts | 133 ++++++++ .../core/integration/integration.listener.ts | 38 +++ .../core/integration/integration.module.ts | 39 +++ .../core/integration/integration.processor.ts | 80 +++++ .../core/integration/integration.service.ts | 91 +++++ .../integration/oauth/oauth.controller.ts | 101 ++++++ .../core/integration/oauth/oauth.service.ts | 321 ++++++++++++++++++ .../integration-provider.interface.ts | 81 +++++ .../registry/integration-registry.ts | 45 +++ .../repos/integration-connection.repo.ts | 135 ++++++++ .../repos/integration-webhook.repo.ts | 101 ++++++ .../integration/repos/integration.repo.ts | 127 +++++++ .../integration/unfurl/unfurl.controller.ts | 35 ++ .../core/integration/unfurl/unfurl.service.ts | 129 +++++++ .../20260222T100000-integrations.ts | 97 ++++++ apps/server/src/database/types/db.d.ts | 42 +++ .../server/src/database/types/db.interface.ts | 8 + .../server/src/database/types/entity.types.ts | 23 ++ apps/server/src/ee | 2 +- .../queue/constants/queue.constants.ts | 3 + .../src/integrations/queue/queue.module.ts | 8 + apps/server/src/main.ts | 1 + package.json | 3 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/integration-link/index.ts | 10 + .../integration-link-patterns.ts | 41 +++ .../lib/integration-link/integration-link.ts | 132 +++++++ pnpm-lock.yaml | 3 + 49 files changed, 2780 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-view.module.css create mode 100644 apps/client/src/features/editor/components/integration-link/integration-link-view.tsx create mode 100644 apps/client/src/features/integration/components/integration-card.tsx create mode 100644 apps/client/src/features/integration/components/integration-settings-modal.tsx create mode 100644 apps/client/src/features/integration/pages/integrations.tsx create mode 100644 apps/client/src/features/integration/queries/integration-query.ts create mode 100644 apps/client/src/features/integration/services/integration-service.ts create mode 100644 apps/client/src/features/integration/types/integration.types.ts create mode 100644 apps/server/src/core/integration/constants.ts create mode 100644 apps/server/src/core/integration/crypto/token-crypto.ts create mode 100644 apps/server/src/core/integration/dto/integration-settings.schema.ts create mode 100644 apps/server/src/core/integration/dto/integration.dto.ts create mode 100644 apps/server/src/core/integration/integration-connection.service.ts create mode 100644 apps/server/src/core/integration/integration.controller.ts create mode 100644 apps/server/src/core/integration/integration.listener.ts create mode 100644 apps/server/src/core/integration/integration.module.ts create mode 100644 apps/server/src/core/integration/integration.processor.ts create mode 100644 apps/server/src/core/integration/integration.service.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.controller.ts create mode 100644 apps/server/src/core/integration/oauth/oauth.service.ts create mode 100644 apps/server/src/core/integration/registry/integration-provider.interface.ts create mode 100644 apps/server/src/core/integration/registry/integration-registry.ts create mode 100644 apps/server/src/core/integration/repos/integration-connection.repo.ts create mode 100644 apps/server/src/core/integration/repos/integration-webhook.repo.ts create mode 100644 apps/server/src/core/integration/repos/integration.repo.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.controller.ts create mode 100644 apps/server/src/core/integration/unfurl/unfurl.service.ts create mode 100644 apps/server/src/database/migrations/20260222T100000-integrations.ts create mode 100644 packages/editor-ext/src/lib/integration-link/index.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts create mode 100644 packages/editor-ext/src/lib/integration-link/integration-link.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 438ffde8..a496bb1f 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> {!isCloud() && } />} {isCloud() && } />} diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx index 1bb30bbd..936d7987 100644 --- a/apps/client/src/components/settings/settings-sidebar.tsx +++ b/apps/client/src/components/settings/settings-sidebar.tsx @@ -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, + }, ], }, { diff --git a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx index 67bb9f82..8f5cefa4 100644 --- a/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx +++ b/apps/client/src/features/editor/components/bubble-menu/link-selector.tsx @@ -34,7 +34,7 @@ export const LinkSelector: FC = ({ withArrow > - + { 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(); diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css new file mode 100644 index 00000000..5061c3df --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.module.css @@ -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); +} diff --git a/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx new file mode 100644 index 00000000..0d4a320d --- /dev/null +++ b/apps/client/src/features/editor/components/integration-link/integration-link-view.tsx @@ -0,0 +1,144 @@ +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 = { + 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", +}; + +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 ( + + + + + + + + + + + + ); + } + + if (status === "error" || !unfurlData) { + return ( + + + + {url} + + + + ); + } + + const iconUrl = providerIcons[provider] ?? undefined; + + return ( + + + + {unfurlData.authorAvatarUrl ? ( + + ) : iconUrl ? ( + + ) : null} + + + + + {unfurlData.title} + + {unfurlData.status && ( + + {unfurlData.status} + + )} + + + {unfurlData.description && ( + + {unfurlData.description} + + )} + + + {iconUrl && ( + + )} + + {unfurlData.provider} + + {unfurlData.author && ( + + ยท {unfurlData.author} + + )} + + + + + + ); +} + +export default memo(IntegrationLinkView); diff --git a/apps/client/src/features/editor/components/link/link-preview.tsx b/apps/client/src/features/editor/components/link/link-preview.tsx index 8b0de952..ef9ed45f 100644 --- a/apps/client/src/features/editor/components/link/link-preview.tsx +++ b/apps/client/src/features/editor/components/link/link-preview.tsx @@ -27,7 +27,7 @@ export const LinkPreviewPanel = ({ <> - + = { + 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 ( + + + + +
+ + {definition.name} + + + {definition.description} + +
+
+
+ + + {definition.capabilities.map((cap) => ( + + {cap} + + ))} + + + {isInstalled ? ( + + + onToggle(installation, e.currentTarget.checked)} + size="sm" + /> + + + + + + + ) : ( + + )} +
+ ); +} diff --git a/apps/client/src/features/integration/components/integration-settings-modal.tsx b/apps/client/src/features/integration/components/integration-settings-modal.tsx new file mode 100644 index 00000000..b32908e0 --- /dev/null +++ b/apps/client/src/features/integration/components/integration-settings-modal.tsx @@ -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 ( + + + {hasOAuth && ( +
+ + {t("Connection")} + + {connectionStatus?.connected ? ( + + + {t("Connected")} + {connectionStatus.providerUserId && + ` (${connectionStatus.providerUserId})`} + + + + ) : ( + + )} +
+ )} +
+
+ ); +} diff --git a/apps/client/src/features/integration/pages/integrations.tsx b/apps/client/src/features/integration/pages/integrations.tsx new file mode 100644 index 00000000..eb4f14b9 --- /dev/null +++ b/apps/client/src/features/integration/pages/integrations.tsx @@ -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(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 ( + <> + + + {t("Integrations")} - {getAppName()} + + + + + + {error === "oauth_failed" && ( + + {t("OAuth connection failed. Please try again.")} + + )} + + {isLoading ? ( +
+ +
+ ) : !available?.length ? ( + + {t("No integrations available.")} + + ) : ( + + {available.map((def) => { + const installation = installed?.find((i) => i.type === def.type); + return ( + + ); + })} + + )} + + setConfiguring(null)} + /> + + ); +} diff --git a/apps/client/src/features/integration/queries/integration-query.ts b/apps/client/src/features/integration/queries/integration-query.ts new file mode 100644 index 00000000..85dfc241 --- /dev/null +++ b/apps/client/src/features/integration/queries/integration-query.ts @@ -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", + }); + }, + }); +} diff --git a/apps/client/src/features/integration/services/integration-service.ts b/apps/client/src/features/integration/services/integration-service.ts new file mode 100644 index 00000000..a53517d1 --- /dev/null +++ b/apps/client/src/features/integration/services/integration-service.ts @@ -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( + "/integrations/available", + ); + return req.data; +} + +export async function getInstalledIntegrations(): Promise { + const req = await api.post("/integrations/list"); + return req.data; +} + +export async function installIntegration(data: { + type: string; +}): Promise { + const req = await api.post("/integrations/install", data); + return req.data; +} + +export async function uninstallIntegration(data: { + integrationId: string; +}): Promise { + await api.post("/integrations/uninstall", data); +} + +export async function updateIntegrationSettings(data: { + integrationId: string; + settings?: Record; + isEnabled?: boolean; +}): Promise { + const req = await api.post("/integrations/update", data); + return req.data; +} + +export async function getConnectionStatus(data: { + integrationId: string; +}): Promise { + const req = await api.post( + "/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 { + await api.post("/integrations/oauth/disconnect", data); +} + +export async function unfurlUrl(data: { + url: string; +}): Promise { + const req = await api.post<{ data: UnfurlResult | null }>( + "/integrations/unfurl", + data, + ); + return req.data.data; +} diff --git a/apps/client/src/features/integration/types/integration.types.ts b/apps/client/src/features/integration/types/integration.types.ts new file mode 100644 index 00000000..7e982bf1 --- /dev/null +++ b/apps/client/src/features/integration/types/integration.types.ts @@ -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 | 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; +}; diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index c4a13093..d3a1b30e 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -25,6 +25,7 @@ const APP_ROUTE = { SPACES: "/settings/spaces", BILLING: "/settings/billing", SECURITY: "/settings/security", + INTEGRATIONS: "/settings/integrations", }, }, }; diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts index f8b75cd0..c73db279 100644 --- a/apps/server/src/core/core.module.ts +++ b/apps/server/src/core/core.module.ts @@ -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('*'); } diff --git a/apps/server/src/core/integration/constants.ts b/apps/server/src/core/integration/constants.ts new file mode 100644 index 00000000..0a55246a --- /dev/null +++ b/apps/server/src/core/integration/constants.ts @@ -0,0 +1,7 @@ +export enum IntegrationType { + SLACK = 'slack', + GITHUB = 'github', + GITLAB = 'gitlab', + JIRA = 'jira', + LINEAR = 'linear', +} diff --git a/apps/server/src/core/integration/crypto/token-crypto.ts b/apps/server/src/core/integration/crypto/token-crypto.ts new file mode 100644 index 00000000..9e3de9be --- /dev/null +++ b/apps/server/src/core/integration/crypto/token-crypto.ts @@ -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; +} diff --git a/apps/server/src/core/integration/dto/integration-settings.schema.ts b/apps/server/src/core/integration/dto/integration-settings.schema.ts new file mode 100644 index 00000000..71bcfbff --- /dev/null +++ b/apps/server/src/core/integration/dto/integration-settings.schema.ts @@ -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 = { + slack: slackSettingsSchema, + github: githubSettingsSchema, + gitlab: gitlabSettingsSchema, + jira: jiraSettingsSchema, + linear: linearSettingsSchema, +}; + +export function validateIntegrationSettings( + type: string, + settings: unknown, +): { success: true; data: Record } | { success: false; error: string } { + const schema = integrationSettingsSchemas[type]; + if (!schema) { + if (settings && typeof settings === 'object') { + return { success: true, data: settings as Record }; + } + 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; +export type GithubSettings = z.infer; +export type GitlabSettings = z.infer; +export type JiraSettings = z.infer; +export type LinearSettings = z.infer; diff --git a/apps/server/src/core/integration/dto/integration.dto.ts b/apps/server/src/core/integration/dto/integration.dto.ts new file mode 100644 index 00000000..f5cb3ca8 --- /dev/null +++ b/apps/server/src/core/integration/dto/integration.dto.ts @@ -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; + + @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; +} diff --git a/apps/server/src/core/integration/integration-connection.service.ts b/apps/server/src/core/integration/integration-connection.service.ts new file mode 100644 index 00000000..81975934 --- /dev/null +++ b/apps/server/src/core/integration/integration-connection.service.ts @@ -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 { + return this.connectionRepo.findByIntegrationAndUser(integrationId, userId); + } + + async findByWorkspaceTypeAndUser( + workspaceId: string, + integrationType: string, + userId: string, + ): Promise { + return this.connectionRepo.findByWorkspaceTypeAndUser( + workspaceId, + integrationType, + userId, + ); + } + + async disconnect( + integrationId: string, + userId: string, + workspaceId: string, + ): Promise { + const integration = await this.integrationRepo.findById(integrationId); + if (!integration || integration.workspaceId !== workspaceId) { + throw new NotFoundException('Integration not found'); + } + + await this.connectionRepo.deleteByIntegrationAndUser( + integrationId, + userId, + ); + } +} diff --git a/apps/server/src/core/integration/integration.controller.ts b/apps/server/src/core/integration/integration.controller.ts new file mode 100644 index 00000000..63b53ed9 --- /dev/null +++ b/apps/server/src/core/integration/integration.controller.ts @@ -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, + ); + } +} diff --git a/apps/server/src/core/integration/integration.listener.ts b/apps/server/src/core/integration/integration.listener.ts new file mode 100644 index 00000000..e2a7239a --- /dev/null +++ b/apps/server/src/core/integration/integration.listener.ts @@ -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, + }); + } +} diff --git a/apps/server/src/core/integration/integration.module.ts b/apps/server/src/core/integration/integration.module.ts new file mode 100644 index 00000000..035cd854 --- /dev/null +++ b/apps/server/src/core/integration/integration.module.ts @@ -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 {} diff --git a/apps/server/src/core/integration/integration.processor.ts b/apps/server/src/core/integration/integration.processor.ts new file mode 100644 index 00000000..ad856cd3 --- /dev/null +++ b/apps/server/src/core/integration/integration.processor.ts @@ -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 { + 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 { + 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 | null, + }, + connection: connection + ? { accessToken, userId: connection.userId } + : undefined, + }); + } catch (err) { + this.logger.error( + `Integration event handler failed for ${integration.type}: ${(err as Error).message}`, + ); + } + } + } +} diff --git a/apps/server/src/core/integration/integration.service.ts b/apps/server/src/core/integration/integration.service.ts new file mode 100644 index 00000000..8ba80a8c --- /dev/null +++ b/apps/server/src/core/integration/integration.service.ts @@ -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 { + return this.integrationRepo.findAllByWorkspace(workspaceId); + } + + async findById(integrationId: string): Promise { + return this.integrationRepo.findById(integrationId); + } + + async install( + type: string, + workspaceId: string, + userId: string, + ): Promise { + 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 { + 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; isEnabled?: boolean }, + ): Promise { + 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 }), + }); + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.controller.ts b/apps/server/src/core/integration/oauth/oauth.controller.ts new file mode 100644 index 00000000..328bafbc --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.controller.ts @@ -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`); + } 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`); + } + } + + @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 }; + } +} diff --git a/apps/server/src/core/integration/oauth/oauth.service.ts b/apps/server/src/core/integration/oauth/oauth.service.ts new file mode 100644 index 00000000..0370cbdf --- /dev/null +++ b/apps/server/src/core/integration/oauth/oauth.service.ts @@ -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) ?? {}) + : 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 { + 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) ?? {}; + + 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 { + 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 { + 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) ?? {}) + : 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 { + 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; + } +} diff --git a/apps/server/src/core/integration/registry/integration-provider.interface.ts b/apps/server/src/core/integration/registry/integration-provider.interface.ts new file mode 100644 index 00000000..e19c5f3a --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-provider.interface.ts @@ -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; +}; + +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; +}; + +export type HandleEventOpts = { + eventName: string; + payload: Record; + integration: { + id: string; + type: string; + settings: Record | 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, + ): OAuthConfig; + + getUnfurlPatterns?( + workspaceSettings: Record, + ): UnfurlPattern[]; + + onConnected?(opts: ConnectedEvent): Promise; + + unfurl?(opts: UnfurlOpts): Promise; + + handleEvent?(opts: HandleEventOpts): Promise; +} diff --git a/apps/server/src/core/integration/registry/integration-registry.ts b/apps/server/src/core/integration/registry/integration-registry.ts new file mode 100644 index 00000000..0c2f9828 --- /dev/null +++ b/apps/server/src/core/integration/registry/integration-registry.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationDefinition, + IntegrationProvider, +} from './integration-provider.interface'; + +@Injectable() +export class IntegrationRegistry { + private providers = new Map(); + + 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; + } +} diff --git a/apps/server/src/core/integration/repos/integration-connection.repo.ts b/apps/server/src/core/integration/repos/integration-connection.repo.ts new file mode 100644 index 00000000..53f557bc --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-connection.repo.ts @@ -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 { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('id', '=', connectionId) + .executeTakeFirst(); + } + + async findByIntegrationAndUser( + integrationId: string, + userId: string, + trx?: KyselyTransaction, + ): Promise { + 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 { + 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 { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationConnections') + .selectAll() + .where('integrationId', '=', integrationId) + .execute(); + } + + async upsert( + connection: InsertableIntegrationConnection, + trx?: KyselyTransaction, + ): Promise { + 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 { + 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 { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .where('userId', '=', userId) + .execute(); + } + + async deleteByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationConnections') + .where('integrationId', '=', integrationId) + .execute(); + } +} diff --git a/apps/server/src/core/integration/repos/integration-webhook.repo.ts b/apps/server/src/core/integration/repos/integration-webhook.repo.ts new file mode 100644 index 00000000..cfaf4f0c --- /dev/null +++ b/apps/server/src/core/integration/repos/integration-webhook.repo.ts @@ -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 { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationWebhooks') + .selectAll() + .where('id', '=', webhookId) + .executeTakeFirst(); + } + + async findByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('integrationWebhooks') + .selectAll() + .where('integrationId', '=', integrationId) + .execute(); + } + + async findEnabledByEvent( + workspaceId: string, + eventType: string, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + return db + .insertInto('integrationWebhooks') + .values(webhook) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async update( + webhookId: string, + data: UpdatableIntegrationWebhook, + trx?: KyselyTransaction, + ): Promise { + 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 { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationWebhooks') + .where('id', '=', webhookId) + .execute(); + } + + async deleteByIntegration( + integrationId: string, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + await db + .deleteFrom('integrationWebhooks') + .where('integrationId', '=', integrationId) + .execute(); + } +} diff --git a/apps/server/src/core/integration/repos/integration.repo.ts b/apps/server/src/core/integration/repos/integration.repo.ts new file mode 100644 index 00000000..2969aa78 --- /dev/null +++ b/apps/server/src/core/integration/repos/integration.repo.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const db = dbOrTx(this.db, trx); + return db + .insertInto('integrations') + .values(integration) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async insertOrRestore( + integration: InsertableIntegration, + trx?: KyselyTransaction, + ): Promise { + 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 { + 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 { + const db = dbOrTx(this.db, trx); + await db + .updateTable('integrations') + .set({ deletedAt: new Date() }) + .where('id', '=', integrationId) + .execute(); + } +} diff --git a/apps/server/src/core/integration/unfurl/unfurl.controller.ts b/apps/server/src/core/integration/unfurl/unfurl.controller.ts new file mode 100644 index 00000000..5e291122 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.controller.ts @@ -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 }; + } +} diff --git a/apps/server/src/core/integration/unfurl/unfurl.service.ts b/apps/server/src/core/integration/unfurl/unfurl.service.ts new file mode 100644 index 00000000..8c3551e7 --- /dev/null +++ b/apps/server/src/core/integration/unfurl/unfurl.service.ts @@ -0,0 +1,129 @@ +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 { + 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) ?? {}; + 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}`; + } +} diff --git a/apps/server/src/database/migrations/20260222T100000-integrations.ts b/apps/server/src/database/migrations/20260222T100000-integrations.ts new file mode 100644 index 00000000..54aaa38e --- /dev/null +++ b/apps/server/src/database/migrations/20260222T100000-integrations.ts @@ -0,0 +1,97 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema.dropTable('integration_webhooks').execute(); + await db.schema.dropTable('integration_connections').execute(); + await db.schema.dropTable('integrations').execute(); +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 6668398b..bdd1d3b3 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -390,6 +390,45 @@ export interface Watchers { createdAt: Generated; } +export interface Integrations { + id: Generated; + workspaceId: string; + type: string; + isEnabled: Generated; + settings: Json | null; + installedById: string | null; + createdAt: Generated; + updatedAt: Generated; + deletedAt: Timestamp | null; +} + +export interface IntegrationConnections { + id: Generated; + 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; + updatedAt: Generated; +} + +export interface IntegrationWebhooks { + id: Generated; + integrationId: string; + workspaceId: string; + eventType: string; + webhookUrl: string | null; + secret: string | null; + isEnabled: Generated; + createdAt: Generated; + updatedAt: Generated; +} + 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; diff --git a/apps/server/src/database/types/db.interface.ts b/apps/server/src/database/types/db.interface.ts index be66fd8c..75be9a4d 100644 --- a/apps/server/src/database/types/db.interface.ts +++ b/apps/server/src/database/types/db.interface.ts @@ -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; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 65e1024a..5c9c350d 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -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>; export type Watcher = Selectable; export type InsertableWatcher = Insertable; export type UpdatableWatcher = Updateable>; + +// Integration +export type Integration = Selectable<_Integrations>; +export type InsertableIntegration = Insertable<_Integrations>; +export type UpdatableIntegration = Updateable>; + +// 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'> +>; diff --git a/apps/server/src/ee b/apps/server/src/ee index 71b4323d..41b27c95 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 71b4323d1b6ea3fbec061b0d31be33235d4ddbcd +Subproject commit 41b27c951fff9b1a10118dc38f671dbff6b77cfc diff --git a/apps/server/src/integrations/queue/constants/queue.constants.ts b/apps/server/src/integrations/queue/constants/queue.constants.ts index a60fc184..3a2608ea 100644 --- a/apps/server/src/integrations/queue/constants/queue.constants.ts +++ b/apps/server/src/integrations/queue/constants/queue.constants.ts @@ -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', } diff --git a/apps/server/src/integrations/queue/queue.module.ts b/apps/server/src/integrations/queue/queue.module.ts index 6268977f..26961d33 100644 --- a/apps/server/src/integrations/queue/queue.module.ts +++ b/apps/server/src/integrations/queue/queue.module.ts @@ -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], diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index d4c8dc17..a8524ddc 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -67,6 +67,7 @@ async function bootstrap() { '/api/sso/google', '/api/workspace/create', '/api/workspace/joined', + '/api/integrations/oauth' ]; if ( diff --git a/package.json b/package.json index 7106d562..e5d4273e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 102cc4b1..3deffb5e 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/integration-link/index.ts b/packages/editor-ext/src/lib/integration-link/index.ts new file mode 100644 index 00000000..d8aeafb7 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/index.ts @@ -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"; diff --git a/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts new file mode 100644 index 00000000..ccfa2c90 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link-patterns.ts @@ -0,0 +1,41 @@ +export type IntegrationLinkPattern = { + provider: string; + regex: RegExp; +}; + +export const integrationLinkPatterns: IntegrationLinkPattern[] = [ + // GitHub (cloud + GHE): /:owner/:repo/pull/:num or /issues/:num + { + provider: "github", + regex: + /^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/(pull|issues)\/(\d+)/, + }, + // GitLab (cloud + self-hosted): /-/issues/:num or /-/merge_requests/:num + { + provider: "gitlab", + regex: + /^https?:\/\/[^\/]+\/(.+)\/-\/(issues|merge_requests)\/(\d+)/, + }, + // Jira (cloud + server): /browse/KEY-123 + { + provider: "jira", + regex: /^https?:\/\/[^\/]+\/browse\/([A-Z][A-Z0-9]+-\d+)/, + }, + // Linear (cloud only): /team/issue/KEY-123 + { + provider: "linear", + regex: /^https?:\/\/linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)/, + }, +]; + +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; +} diff --git a/packages/editor-ext/src/lib/integration-link/integration-link.ts b/packages/editor-ext/src/lib/integration-link/integration-link.ts new file mode 100644 index 00000000..9a1a6c00 --- /dev/null +++ b/packages/editor-ext/src/lib/integration-link/integration-link.ts @@ -0,0 +1,132 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { sanitizeUrl } from "../utils"; + +export interface IntegrationLinkOptions { + HTMLAttributes: Record; + view: any; +} + +export interface IntegrationLinkAttributes { + url: string; + provider: string; + unfurlData: Record | null; + status: "pending" | "loaded" | "error"; +} + +declare module "@tiptap/core" { + interface Commands { + integrationLink: { + setIntegrationLink: ( + attributes: Partial, + ) => ReturnType; + }; + } +} + +export const IntegrationLink = Node.create({ + 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); + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 161aa6f1..09db9c7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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