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 ( + + {`stripe + + ) + } + if (config.name === 'Hubspot') { + return ( + + + Hubspot + + + ) } - return ( - - {`stripe - - ) + 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"