diff --git a/frontend/src/scenes/data-warehouse/external/SourceModal.tsx b/frontend/src/scenes/data-warehouse/external/SourceModal.tsx
index e09050ca89d18..94367360fdbde 100644
--- a/frontend/src/scenes/data-warehouse/external/SourceModal.tsx
+++ b/frontend/src/scenes/data-warehouse/external/SourceModal.tsx
@@ -1,4 +1,4 @@
-import { LemonButton, LemonDivider, LemonInput, LemonModal, LemonModalProps } from '@posthog/lemon-ui'
+import { LemonButton, LemonDivider, LemonInput, LemonModal, LemonModalProps, Link } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { Field } from 'lib/forms/Field'
@@ -10,21 +10,40 @@ import { ConnectorConfigType, sourceModalLogic } from './sourceModalLogic'
interface SourceModalProps extends LemonModalProps {}
export default function SourceModal(props: SourceModalProps): JSX.Element {
- const { tableLoading, isExternalDataSourceSubmitting, selectedConnector, isManualLinkFormVisible, connectors } =
- useValues(sourceModalLogic)
+ const {
+ tableLoading,
+ isExternalDataSourceSubmitting,
+ selectedConnector,
+ isManualLinkFormVisible,
+ connectors,
+ addToHubspotUrl,
+ } = useValues(sourceModalLogic)
const { selectConnector, toggleManualLinkFormVisible, resetExternalDataSource, resetTable } =
useActions(sourceModalLogic)
const MenuButton = (config: ConnectorConfigType): JSX.Element => {
- const onClick = (): void => {
- selectConnector(config)
+ if (config.name === 'Stripe') {
+ const onClick = (): void => {
+ selectConnector(config)
+ }
+
+ return (
+
+
+
+ )
+ }
+ if (config.name === 'Hubspot') {
+ return (
+
+
+ Hubspot
+
+
+ )
}
- return (
-
-
-
- )
+ return <>>
}
const onClear = (): void => {
diff --git a/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts b/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts
index 13e6976b2f988..a8e9fb4b7e63e 100644
--- a/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts
+++ b/frontend/src/scenes/data-warehouse/external/sourceModalLogic.ts
@@ -3,6 +3,7 @@ import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea
import { forms } from 'kea-forms'
import { router } from 'kea-router'
import api from 'lib/api'
+import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { urls } from 'scenes/urls'
import { ExternalDataStripeSourceCreatePayload } from '~/types'
@@ -12,6 +13,10 @@ import { dataWarehouseSettingsLogic } from '../settings/dataWarehouseSettingsLog
import { dataWarehouseSceneLogic } from './dataWarehouseSceneLogic'
import type { sourceModalLogicType } from './sourceModalLogicType'
+export const getHubspotRedirectUri = (next: string = ''): string =>
+ `${window.location.origin.replace('http://', 'https://')}/data_warehouse/hubspot/redirect${
+ next ? '?next=' + encodeURIComponent(next) : ''
+ }`
export interface ConnectorConfigType {
name: string
fields: string[]
@@ -23,10 +28,16 @@ export interface ConnectorConfigType {
export const CONNECTORS: ConnectorConfigType[] = [
{
name: 'Stripe',
- fields: ['accound_id', 'client_secret'],
+ fields: ['account_id', 'client_secret'],
caption: 'Enter your Stripe credentials to link your Stripe to PostHog',
disabledReason: null,
},
+ {
+ name: 'Hubspot',
+ fields: [],
+ caption: '',
+ disabledReason: null,
+ },
]
export const sourceModalLogic = kea([
@@ -36,7 +47,14 @@ export const sourceModalLogic = kea([
toggleManualLinkFormVisible: (visible: boolean) => ({ visible }),
}),
connect({
- values: [dataWarehouseTableLogic, ['tableLoading'], dataWarehouseSettingsLogic, ['dataWarehouseSources']],
+ values: [
+ dataWarehouseTableLogic,
+ ['tableLoading'],
+ dataWarehouseSettingsLogic,
+ ['dataWarehouseSources'],
+ preflightLogic,
+ ['preflight'],
+ ],
actions: [
dataWarehouseSceneLogic,
['toggleSourceModal'],
@@ -77,6 +95,27 @@ export const sourceModalLogic = kea([
}))
},
],
+ addToHubspotUrl: [
+ (s) => [s.preflight],
+ (preflight) => {
+ return (next: string = '') => {
+ const clientId = preflight?.data_warehouse_integrations?.hubspot.client_id
+
+ if (!clientId) {
+ return null
+ }
+
+ const scopes = ['crm.objects.contacts.read', 'crm.objects.companies.read']
+
+ const params = new URLSearchParams()
+ params.set('client_id', clientId)
+ params.set('redirect_uri', getHubspotRedirectUri(next))
+ params.set('scope', scopes.join(' '))
+
+ return `https://app.hubspot.com/oauth/authorize?${params.toString()}`
+ }
+ },
+ ],
}),
forms(() => ({
externalDataSource: {
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 36483bbdd9eb5..4d30288b98baa 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -2500,6 +2500,11 @@ export interface PreflightStatus {
available: boolean
client_id?: string
}
+ data_warehouse_integrations: {
+ hubspot: {
+ client_id?: string
+ }
+ }
/** Whether PostHog is running in DEBUG mode. */
is_debug?: boolean
licensed_users_available?: number | null
diff --git a/posthog/settings/__init__.py b/posthog/settings/__init__.py
index 7ee845e134694..3a3225e7deed5 100644
--- a/posthog/settings/__init__.py
+++ b/posthog/settings/__init__.py
@@ -39,7 +39,7 @@
from posthog.settings.object_storage import *
from posthog.settings.temporal import *
from posthog.settings.web import *
-from posthog.settings.airbyte import *
+from posthog.settings.data_warehouse import *
from posthog.settings.utils import get_from_env, str_to_bool
diff --git a/posthog/settings/airbyte.py b/posthog/settings/data_warehouse.py
similarity index 75%
rename from posthog/settings/airbyte.py
rename to posthog/settings/data_warehouse.py
index bcbcf2fefacb5..b53e16e570a13 100644
--- a/posthog/settings/airbyte.py
+++ b/posthog/settings/data_warehouse.py
@@ -8,3 +8,6 @@
# for DLT
BUCKET_URL = os.getenv("BUCKET_URL", None)
AIRBYTE_BUCKET_NAME = os.getenv("AIRBYTE_BUCKET_NAME", None)
+
+HUBSPOT_APP_CLIENT_ID = os.getenv("HUBSPOT_APP_CLIENT_ID", None)
+HUBSPOT_APP_CLIENT_SECRET = os.getenv("HUBSPOT_APP_CLIENT_SECRET", None)
diff --git a/posthog/temporal/data_imports/pipelines/hubspot/auth.py b/posthog/temporal/data_imports/pipelines/hubspot/auth.py
new file mode 100644
index 0000000000000..92fa1a389aaa5
--- /dev/null
+++ b/posthog/temporal/data_imports/pipelines/hubspot/auth.py
@@ -0,0 +1,42 @@
+import requests
+from django.conf import settings
+from typing import Tuple
+
+
+def refresh_access_token(refresh_token: str) -> str:
+ res = requests.post(
+ "https://api.hubapi.com/oauth/v1/token",
+ data={
+ "grant_type": "refresh_token",
+ "client_id": settings.HUBSPOT_APP_CLIENT_ID,
+ "client_secret": settings.HUBSPOT_APP_CLIENT_SECRET,
+ "refresh_token": refresh_token,
+ },
+ )
+
+ if res.status_code != 200:
+ err_message = res.json()["message"]
+ raise Exception(err_message)
+
+ return res.json()["access_token"]
+
+
+def get_access_token_from_code(code: str) -> Tuple[str, str]:
+ res = requests.post(
+ "https://api.hubapi.com/oauth/v1/token",
+ data={
+ "grant_type": "authorization_code",
+ "client_id": settings.HUBSPOT_APP_CLIENT_ID,
+ "client_secret": settings.HUBSPOT_APP_CLIENT_SECRET,
+ "redirect_uri": "https://app.posthog.com/setup/hubspot",
+ "code": code,
+ },
+ )
+
+ if res.status_code != 200:
+ err_message = res.json()["message"]
+ raise Exception(err_message)
+
+ payload = res.json()
+
+ return payload["access_token"], payload["refresh_token"]
diff --git a/posthog/views.py b/posthog/views.py
index 4750cf170cc27..1f757f9833734 100644
--- a/posthog/views.py
+++ b/posthog/views.py
@@ -92,6 +92,7 @@ def security_txt(request):
@never_cache
def preflight_check(request: HttpRequest) -> JsonResponse:
slack_client_id = SlackIntegration.slack_config().get("SLACK_APP_CLIENT_ID")
+ hubspot_client_id = settings.HUBSPOT_APP_CLIENT_ID
response = {
"django": True,
@@ -113,6 +114,7 @@ def preflight_check(request: HttpRequest) -> JsonResponse:
"available": bool(slack_client_id),
"client_id": slack_client_id or None,
},
+ "data_warehouse_integrations": {"hubspot": {"client_id": hubspot_client_id}},
"object_storage": is_cloud() or is_object_storage_available(),
}
diff --git a/posthog/warehouse/api/external_data_source.py b/posthog/warehouse/api/external_data_source.py
index 48f8babed4a5a..c52d30746140c 100644
--- a/posthog/warehouse/api/external_data_source.py
+++ b/posthog/warehouse/api/external_data_source.py
@@ -25,6 +25,9 @@
from posthog.temporal.data_imports.pipelines.schemas import (
PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING,
)
+from posthog.temporal.data_imports.pipelines.hubspot.auth import (
+ get_access_token_from_code,
+)
import temporalio
logger = structlog.get_logger(__name__)
@@ -107,7 +110,6 @@ def get_queryset(self):
)
def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
- client_secret = request.data["client_secret"]
prefix = request.data.get("prefix", None)
source_type = request.data["source_type"]
@@ -127,18 +129,12 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
)
# TODO: remove dummy vars
- new_source_model = ExternalDataSource.objects.create(
- source_id=str(uuid.uuid4()),
- connection_id=str(uuid.uuid4()),
- destination_id=str(uuid.uuid4()),
- team=self.team,
- status="Running",
- source_type=source_type,
- job_inputs={
- "stripe_secret_key": client_secret,
- },
- prefix=prefix,
- )
+ if source_type == ExternalDataSource.Type.STRIPE:
+ new_source_model = self._handle_stripe_source(request, *args, **kwargs)
+ elif source_type == ExternalDataSource.Type.HUBSPOT:
+ new_source_model = self._handle_hubspot_source(request, *args, **kwargs)
+ else:
+ raise NotImplementedError(f"Source type {source_type} not implemented")
schemas = PIPELINE_TYPE_SCHEMA_DEFAULT_MAPPING[source_type]
for schema in schemas:
@@ -156,6 +152,51 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response:
return Response(status=status.HTTP_201_CREATED, data={"id": new_source_model.pk})
+ def _handle_stripe_source(self, request: Request, *args: Any, **kwargs: Any) -> ExternalDataSource:
+ client_secret = request.data["client_secret"]
+ prefix = request.data.get("prefix", None)
+ source_type = request.data["source_type"]
+
+ # TODO: remove dummy vars
+ new_source_model = ExternalDataSource.objects.create(
+ source_id=str(uuid.uuid4()),
+ connection_id=str(uuid.uuid4()),
+ destination_id=str(uuid.uuid4()),
+ team=self.team,
+ status="Running",
+ source_type=source_type,
+ job_inputs={
+ "stripe_secret_key": client_secret,
+ },
+ prefix=prefix,
+ )
+
+ return new_source_model
+
+ def _handle_hubspot_source(self, request: Request, *args: Any, **kwargs: Any) -> ExternalDataSource:
+ code = request.data["code"]
+ prefix = request.data.get("prefix", None)
+ source_type = request.data["source_type"]
+
+ access_token, refresh_token = get_access_token_from_code(code)
+
+ # TODO: remove dummy vars
+ new_source_model = ExternalDataSource.objects.create(
+ source_id=str(uuid.uuid4()),
+ connection_id=str(uuid.uuid4()),
+ destination_id=str(uuid.uuid4()),
+ team=self.team,
+ status="Running",
+ source_type=source_type,
+ job_inputs={
+ "hubspot_secret_key": access_token,
+ "hubspot_refresh_token": refresh_token,
+ },
+ prefix=prefix,
+ )
+
+ return new_source_model
+
def prefix_required(self, source_type: str) -> bool:
source_type_exists = ExternalDataSource.objects.filter(team_id=self.team.pk, source_type=source_type).exists()
return source_type_exists
diff --git a/posthog/warehouse/models/external_data_source.py b/posthog/warehouse/models/external_data_source.py
index 287a4a3f2cd99..5d8f736a77b94 100644
--- a/posthog/warehouse/models/external_data_source.py
+++ b/posthog/warehouse/models/external_data_source.py
@@ -8,6 +8,7 @@
class ExternalDataSource(CreatedMetaFields, UUIDModel):
class Type(models.TextChoices):
STRIPE = "Stripe", "Stripe"
+ HUBSPOT = "Hubspot", "Hubspot"
class Status(models.TextChoices):
RUNNING = "Running", "Running"