mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat(cdp): add linkedin integration (#26282)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
BIN
frontend/public/services/linkedin.png
Normal file
BIN
frontend/public/services/linkedin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
@@ -69,6 +69,8 @@ import {
|
|||||||
HogFunctionTypeType,
|
HogFunctionTypeType,
|
||||||
InsightModel,
|
InsightModel,
|
||||||
IntegrationType,
|
IntegrationType,
|
||||||
|
LinkedInAdsAccountType,
|
||||||
|
LinkedInAdsConversionRuleType,
|
||||||
ListOrganizationMembersParams,
|
ListOrganizationMembersParams,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
LogEntryRequestParams,
|
LogEntryRequestParams,
|
||||||
@@ -820,6 +822,21 @@ class ApiRequest {
|
|||||||
.withQueryString({ customerId })
|
.withQueryString({ customerId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public integrationLinkedInAdsAccounts(id: IntegrationType['id'], teamId?: TeamType['id']): ApiRequest {
|
||||||
|
return this.integrations(teamId).addPathComponent(id).addPathComponent('linkedin_ads_accounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
public integrationLinkedInAdsConversionRules(
|
||||||
|
id: IntegrationType['id'],
|
||||||
|
accountId: string,
|
||||||
|
teamId?: TeamType['id']
|
||||||
|
): ApiRequest {
|
||||||
|
return this.integrations(teamId)
|
||||||
|
.addPathComponent(id)
|
||||||
|
.addPathComponent('linkedin_ads_conversion_rules')
|
||||||
|
.withQueryString({ accountId })
|
||||||
|
}
|
||||||
|
|
||||||
public media(teamId?: TeamType['id']): ApiRequest {
|
public media(teamId?: TeamType['id']): ApiRequest {
|
||||||
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
|
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
|
||||||
}
|
}
|
||||||
@@ -2562,6 +2579,15 @@ const api = {
|
|||||||
): Promise<{ conversionActions: GoogleAdsConversionActionType[] }> {
|
): Promise<{ conversionActions: GoogleAdsConversionActionType[] }> {
|
||||||
return await new ApiRequest().integrationGoogleAdsConversionActions(id, customerId).get()
|
return await new ApiRequest().integrationGoogleAdsConversionActions(id, customerId).get()
|
||||||
},
|
},
|
||||||
|
async linkedInAdsAccounts(id: IntegrationType['id']): Promise<{ adAccounts: LinkedInAdsAccountType[] }> {
|
||||||
|
return await new ApiRequest().integrationLinkedInAdsAccounts(id).get()
|
||||||
|
},
|
||||||
|
async linkedInAdsConversionRules(
|
||||||
|
id: IntegrationType['id'],
|
||||||
|
accountId: string
|
||||||
|
): Promise<{ conversionRules: LinkedInAdsConversionRuleType[] }> {
|
||||||
|
return await new ApiRequest().integrationLinkedInAdsConversionRules(id, accountId).get()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
resourcePermissions: {
|
resourcePermissions: {
|
||||||
|
|||||||
151
frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx
Normal file
151
frontend/src/lib/integrations/LinkedInIntegrationHelpers.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { LemonInputSelect, LemonInputSelectOption } from '@posthog/lemon-ui'
|
||||||
|
import { useActions, useValues } from 'kea'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
|
||||||
|
import { IntegrationType, LinkedInAdsAccountType, LinkedInAdsConversionRuleType } from '~/types'
|
||||||
|
|
||||||
|
import { linkedInAdsIntegrationLogic } from './linkedInAdsIntegrationLogic'
|
||||||
|
|
||||||
|
const getLinkedInAdsAccountOptions = (
|
||||||
|
linkedInAdsAccounts?: LinkedInAdsAccountType[] | null
|
||||||
|
): LemonInputSelectOption[] | null => {
|
||||||
|
return linkedInAdsAccounts
|
||||||
|
? linkedInAdsAccounts.map((account) => ({
|
||||||
|
key: account.id.toString(),
|
||||||
|
labelComponent: (
|
||||||
|
<span className="flex items-center">
|
||||||
|
{account.name} ({account.id.toString()})
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
label: `${account.name}`,
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLinkedInAdsConversionRuleOptions = (
|
||||||
|
linkedInAdsConversionRules?: LinkedInAdsConversionRuleType[] | null
|
||||||
|
): LemonInputSelectOption[] | null => {
|
||||||
|
return linkedInAdsConversionRules
|
||||||
|
? linkedInAdsConversionRules.map(({ id, name }) => ({
|
||||||
|
key: id.toString(),
|
||||||
|
labelComponent: (
|
||||||
|
<span className="flex items-center">
|
||||||
|
{name} ({id})
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
label: `${name} (${id})`,
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkedInAdsPickerProps = {
|
||||||
|
integration: IntegrationType
|
||||||
|
value?: string
|
||||||
|
onChange?: (value: string | null) => void
|
||||||
|
disabled?: boolean
|
||||||
|
requiresFieldValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkedInAdsConversionRulePicker({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
requiresFieldValue,
|
||||||
|
integration,
|
||||||
|
disabled,
|
||||||
|
}: LinkedInAdsPickerProps): JSX.Element {
|
||||||
|
const { linkedInAdsConversionRules, linkedInAdsConversionRulesLoading } = useValues(
|
||||||
|
linkedInAdsIntegrationLogic({ id: integration.id })
|
||||||
|
)
|
||||||
|
const { loadLinkedInAdsConversionRules } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))
|
||||||
|
|
||||||
|
const linkedInAdsConversionRuleOptions = useMemo(
|
||||||
|
() => getLinkedInAdsConversionRuleOptions(linkedInAdsConversionRules),
|
||||||
|
[linkedInAdsConversionRules]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requiresFieldValue) {
|
||||||
|
loadLinkedInAdsConversionRules(requiresFieldValue)
|
||||||
|
}
|
||||||
|
}, [loadLinkedInAdsConversionRules, requiresFieldValue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LemonInputSelect
|
||||||
|
onChange={(val) => onChange?.(val[0] ?? null)}
|
||||||
|
value={value ? [value] : []}
|
||||||
|
onFocus={() =>
|
||||||
|
!linkedInAdsConversionRules &&
|
||||||
|
!linkedInAdsConversionRulesLoading &&
|
||||||
|
requiresFieldValue &&
|
||||||
|
loadLinkedInAdsConversionRules(requiresFieldValue)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
mode="single"
|
||||||
|
data-attr="select-linkedin-ads-conversion-action"
|
||||||
|
placeholder="Select a Conversion Action..."
|
||||||
|
options={
|
||||||
|
linkedInAdsConversionRuleOptions ??
|
||||||
|
(value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: value,
|
||||||
|
label: value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
}
|
||||||
|
loading={linkedInAdsConversionRulesLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkedInAdsAccountIdPicker({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
integration,
|
||||||
|
disabled,
|
||||||
|
}: LinkedInAdsPickerProps): JSX.Element {
|
||||||
|
const { linkedInAdsAccounts, linkedInAdsAccountsLoading } = useValues(
|
||||||
|
linkedInAdsIntegrationLogic({ id: integration.id })
|
||||||
|
)
|
||||||
|
const { loadLinkedInAdsAccounts } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))
|
||||||
|
|
||||||
|
const linkedInAdsAccountOptions = useMemo(
|
||||||
|
() => getLinkedInAdsAccountOptions(linkedInAdsAccounts),
|
||||||
|
[linkedInAdsAccounts]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
loadLinkedInAdsAccounts()
|
||||||
|
}
|
||||||
|
}, [loadLinkedInAdsAccounts])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LemonInputSelect
|
||||||
|
onChange={(val) => onChange?.(val[0] ?? null)}
|
||||||
|
value={value ? [value] : []}
|
||||||
|
onFocus={() => !linkedInAdsAccounts && !linkedInAdsAccountsLoading && loadLinkedInAdsAccounts()}
|
||||||
|
disabled={disabled}
|
||||||
|
mode="single"
|
||||||
|
data-attr="select-linkedin-ads-customer-id-channel"
|
||||||
|
placeholder="Select a Account ID..."
|
||||||
|
options={
|
||||||
|
linkedInAdsAccountOptions ??
|
||||||
|
(value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: value,
|
||||||
|
label: value.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
}
|
||||||
|
loading={linkedInAdsAccountsLoading}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import IconGoogleAds from 'public/services/google-ads.png'
|
|||||||
import IconGoogleCloud from 'public/services/google-cloud.png'
|
import IconGoogleCloud from 'public/services/google-cloud.png'
|
||||||
import IconGoogleCloudStorage from 'public/services/google-cloud-storage.png'
|
import IconGoogleCloudStorage from 'public/services/google-cloud-storage.png'
|
||||||
import IconHubspot from 'public/services/hubspot.png'
|
import IconHubspot from 'public/services/hubspot.png'
|
||||||
|
import IconLinkedIn from 'public/services/linkedin.png'
|
||||||
import IconSalesforce from 'public/services/salesforce.png'
|
import IconSalesforce from 'public/services/salesforce.png'
|
||||||
import IconSlack from 'public/services/slack.png'
|
import IconSlack from 'public/services/slack.png'
|
||||||
import IconSnapchat from 'public/services/snapchat.png'
|
import IconSnapchat from 'public/services/snapchat.png'
|
||||||
@@ -26,6 +27,7 @@ const ICONS: Record<IntegrationKind, any> = {
|
|||||||
'google-cloud-storage': IconGoogleCloudStorage,
|
'google-cloud-storage': IconGoogleCloudStorage,
|
||||||
'google-ads': IconGoogleAds,
|
'google-ads': IconGoogleAds,
|
||||||
snapchat: IconSnapchat,
|
snapchat: IconSnapchat,
|
||||||
|
'linkedin-ads': IconLinkedIn,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const integrationsLogic = kea<integrationsLogicType>([
|
export const integrationsLogic = kea<integrationsLogicType>([
|
||||||
|
|||||||
37
frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts
Normal file
37
frontend/src/lib/integrations/linkedInAdsIntegrationLogic.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { actions, kea, key, path, props } from 'kea'
|
||||||
|
import { loaders } from 'kea-loaders'
|
||||||
|
import api from 'lib/api'
|
||||||
|
|
||||||
|
import { LinkedInAdsAccountType, LinkedInAdsConversionRuleType } from '~/types'
|
||||||
|
|
||||||
|
import type { linkedInAdsIntegrationLogicType } from './linkedInAdsIntegrationLogicType'
|
||||||
|
|
||||||
|
export const linkedInAdsIntegrationLogic = kea<linkedInAdsIntegrationLogicType>([
|
||||||
|
props({} as { id: number }),
|
||||||
|
key((props) => props.id),
|
||||||
|
path((key) => ['lib', 'integrations', 'linkedInAdsIntegrationLogic', key]),
|
||||||
|
actions({
|
||||||
|
loadLinkedInAdsConversionRules: (accountId: string) => accountId,
|
||||||
|
loadLinkedInAdsAccounts: true,
|
||||||
|
}),
|
||||||
|
loaders(({ props }) => ({
|
||||||
|
linkedInAdsConversionRules: [
|
||||||
|
null as LinkedInAdsConversionRuleType[] | null,
|
||||||
|
{
|
||||||
|
loadLinkedInAdsConversionRules: async (customerId: string) => {
|
||||||
|
const res = await api.integrations.linkedInAdsConversionRules(props.id, customerId)
|
||||||
|
return res.conversionRules
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
linkedInAdsAccounts: [
|
||||||
|
null as LinkedInAdsAccountType[] | null,
|
||||||
|
{
|
||||||
|
loadLinkedInAdsAccounts: async () => {
|
||||||
|
const res = await api.integrations.linkedInAdsAccounts(props.id)
|
||||||
|
return res.adAccounts
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
])
|
||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
GoogleAdsCustomerIdPicker,
|
GoogleAdsCustomerIdPicker,
|
||||||
} from 'lib/integrations/GoogleAdsIntegrationHelpers'
|
} from 'lib/integrations/GoogleAdsIntegrationHelpers'
|
||||||
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
|
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
|
||||||
|
import {
|
||||||
|
LinkedInAdsAccountIdPicker,
|
||||||
|
LinkedInAdsConversionRulePicker,
|
||||||
|
} from 'lib/integrations/LinkedInIntegrationHelpers'
|
||||||
import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'
|
import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'
|
||||||
|
|
||||||
import { HogFunctionInputSchemaType } from '~/types'
|
import { HogFunctionInputSchemaType } from '~/types'
|
||||||
@@ -99,6 +103,25 @@ export function HogFunctionInputIntegrationField({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (schema.integration_field === 'linkedin_ads_conversion_rule_id' && requiresFieldValue) {
|
||||||
|
return (
|
||||||
|
<LinkedInAdsConversionRulePicker
|
||||||
|
value={value}
|
||||||
|
requiresFieldValue={requiresFieldValue}
|
||||||
|
onChange={(x) => onChange?.(x?.split('|')[0])}
|
||||||
|
integration={integration}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (schema.integration_field === 'linkedin_ads_account_id') {
|
||||||
|
return (
|
||||||
|
<LinkedInAdsAccountIdPicker
|
||||||
|
value={value}
|
||||||
|
onChange={(x) => onChange?.(x?.split('|')[0])}
|
||||||
|
integration={integration}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="text-danger">
|
<div className="text-danger">
|
||||||
<p>Unsupported integration type: {schema.integration}</p>
|
<p>Unsupported integration type: {schema.integration}</p>
|
||||||
|
|||||||
@@ -3748,6 +3748,7 @@ export type IntegrationKind =
|
|||||||
| 'google-pubsub'
|
| 'google-pubsub'
|
||||||
| 'google-cloud-storage'
|
| 'google-cloud-storage'
|
||||||
| 'google-ads'
|
| 'google-ads'
|
||||||
|
| 'linkedin-ads'
|
||||||
| 'snapchat'
|
| 'snapchat'
|
||||||
|
|
||||||
export interface IntegrationType {
|
export interface IntegrationType {
|
||||||
@@ -4844,6 +4845,17 @@ export type GoogleAdsConversionActionType = {
|
|||||||
resourceName: string
|
resourceName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LinkedInAdsConversionRuleType = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LinkedInAdsAccountType = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
campaigns: string
|
||||||
|
}
|
||||||
|
|
||||||
export type DataColorThemeModel = {
|
export type DataColorThemeModel = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
{
|
{
|
||||||
"disabledLabels": [
|
"disabledLabels": ["no-greptile"]
|
||||||
"no-greptile"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from posthog.models.integration import (
|
|||||||
SlackIntegration,
|
SlackIntegration,
|
||||||
GoogleCloudIntegration,
|
GoogleCloudIntegration,
|
||||||
GoogleAdsIntegration,
|
GoogleAdsIntegration,
|
||||||
|
LinkedInAdsIntegration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,3 +139,36 @@ class IntegrationViewSet(
|
|||||||
response_data = {"accessibleAccounts": google_ads.list_google_ads_accessible_accounts()}
|
response_data = {"accessibleAccounts": google_ads.list_google_ads_accessible_accounts()}
|
||||||
cache.set(key, response_data, 60)
|
cache.set(key, response_data, 60)
|
||||||
return Response(response_data)
|
return Response(response_data)
|
||||||
|
|
||||||
|
@action(methods=["GET"], detail=True, url_path="linkedin_ads_conversion_rules")
|
||||||
|
def linkedin_ad_conversion_rules(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||||
|
instance = self.get_object()
|
||||||
|
linkedin_ads = LinkedInAdsIntegration(instance)
|
||||||
|
account_id = request.query_params.get("accountId")
|
||||||
|
|
||||||
|
response = linkedin_ads.list_linkedin_ads_conversion_rules(account_id)
|
||||||
|
conversion_rules = [
|
||||||
|
{
|
||||||
|
"id": conversionRule["id"],
|
||||||
|
"name": conversionRule["name"],
|
||||||
|
}
|
||||||
|
for conversionRule in (response if isinstance(response, list) else [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({"conversionRules": conversion_rules})
|
||||||
|
|
||||||
|
@action(methods=["GET"], detail=True, url_path="linkedin_ads_accounts")
|
||||||
|
def linkedin_ad_accounts(self, request: Request, *args: Any, **kwargs: Any) -> Response:
|
||||||
|
instance = self.get_object()
|
||||||
|
linkedin_ads = LinkedInAdsIntegration(instance)
|
||||||
|
|
||||||
|
accounts = [
|
||||||
|
{
|
||||||
|
"id": account["id"],
|
||||||
|
"name": account["name"],
|
||||||
|
"reference": account["reference"],
|
||||||
|
}
|
||||||
|
for account in linkedin_ads.list_linkedin_ads_accounts()["elements"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({"adAccounts": accounts})
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .google_ads.template_google_ads import template as google_ads
|
|||||||
from .attio.template_attio import template as attio
|
from .attio.template_attio import template as attio
|
||||||
from .mailchimp.template_mailchimp import template as mailchimp
|
from .mailchimp.template_mailchimp import template as mailchimp
|
||||||
from .microsoft_teams.template_microsoft_teams import template as microsoft_teams
|
from .microsoft_teams.template_microsoft_teams import template as microsoft_teams
|
||||||
|
from .linkedin_ads.template_linkedin_ads import template as linkedin_ads
|
||||||
from .klaviyo.template_klaviyo import template_user as klaviyo_user, template_event as klaviyo_event
|
from .klaviyo.template_klaviyo import template_user as klaviyo_user, template_event as klaviyo_event
|
||||||
from .google_cloud_storage.template_google_cloud_storage import (
|
from .google_cloud_storage.template_google_cloud_storage import (
|
||||||
template as google_cloud_storage,
|
template as google_cloud_storage,
|
||||||
@@ -84,6 +85,7 @@ HOG_FUNCTION_TEMPLATES = [
|
|||||||
klaviyo_event,
|
klaviyo_event,
|
||||||
klaviyo_user,
|
klaviyo_user,
|
||||||
knock,
|
knock,
|
||||||
|
linkedin_ads,
|
||||||
loops,
|
loops,
|
||||||
loops_send_event,
|
loops_send_event,
|
||||||
mailchimp,
|
mailchimp,
|
||||||
|
|||||||
155
posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py
Normal file
155
posthog/cdp/templates/linkedin_ads/template_linkedin_ads.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
|
||||||
|
|
||||||
|
template: HogFunctionTemplate = HogFunctionTemplate(
|
||||||
|
status="alpha",
|
||||||
|
type="destination",
|
||||||
|
id="template-linkedin-ads",
|
||||||
|
name="LinkedIn Ads Conversions",
|
||||||
|
description="Send conversion events to LinkedIn Ads",
|
||||||
|
icon_url="/static/services/linkedin.png",
|
||||||
|
category=["Advertisement"],
|
||||||
|
hog="""
|
||||||
|
let body := {
|
||||||
|
'conversion': f'urn:lla:llaPartnerConversion:{inputs.conversionRuleId}',
|
||||||
|
'conversionHappenedAt': inputs.conversionDateTime,
|
||||||
|
'conversionValue': {},
|
||||||
|
'user': {
|
||||||
|
'userIds': [],
|
||||||
|
'userInfo': {}
|
||||||
|
},
|
||||||
|
'eventId' : inputs.eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (not empty(inputs.currencyCode)) {
|
||||||
|
body.conversionValue.currencyCode := inputs.currencyCode
|
||||||
|
}
|
||||||
|
if (not empty(inputs.conversionValue)) {
|
||||||
|
body.conversionValue.amount := inputs.conversionValue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key, value in inputs.userInfo) {
|
||||||
|
if (not empty(value)) {
|
||||||
|
body.user.userInfo[key] := value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let key, value in inputs.userIds) {
|
||||||
|
if (not empty(value)) {
|
||||||
|
body.user.userIds := arrayPushBack(body.user.userIds, {'idType': key, 'idValue': value})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res := fetch('https://api.linkedin.com/rest/conversionEvents', {
|
||||||
|
'method': 'POST',
|
||||||
|
'headers': {
|
||||||
|
'Authorization': f'Bearer {inputs.oauth.access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'LinkedIn-Version': '202409'
|
||||||
|
},
|
||||||
|
'body': body
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status >= 400) {
|
||||||
|
throw Error(f'Error from api.linkedin.com (status {res.status}): {res.body}')
|
||||||
|
}
|
||||||
|
""".strip(),
|
||||||
|
inputs_schema=[
|
||||||
|
{
|
||||||
|
"key": "oauth",
|
||||||
|
"type": "integration",
|
||||||
|
"integration": "linkedin-ads",
|
||||||
|
"label": "LinkedIn Ads account",
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "accountId",
|
||||||
|
"type": "integration_field",
|
||||||
|
"integration_key": "oauth",
|
||||||
|
"integration_field": "linkedin_ads_account_id",
|
||||||
|
"label": "Account ID",
|
||||||
|
"description": "ID of your LinkedIn Ads Account. This should be 9-digits and in XXXXXXXXX format.",
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversionRuleId",
|
||||||
|
"type": "integration_field",
|
||||||
|
"integration_key": "oauth",
|
||||||
|
"integration_field": "linkedin_ads_conversion_rule_id",
|
||||||
|
"requires_field": "accountId",
|
||||||
|
"label": "Conversion rule",
|
||||||
|
"description": "The Conversion rule associated with this conversion.",
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversionDateTime",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Conversion Date Time",
|
||||||
|
"description": "The timestamp at which the conversion occurred in milliseconds. Must be after the click time.",
|
||||||
|
"default": "{toUnixTimestampMilli(event.timestamp)}",
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "conversionValue",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Conversion value",
|
||||||
|
"description": "The value of the conversion for the advertiser in decimal string. (e.g. “100.05”).",
|
||||||
|
"default": "",
|
||||||
|
"secret": False,
|
||||||
|
"required": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "currencyCode",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Currency code",
|
||||||
|
"description": "Currency associated with the conversion value. This is the ISO 4217 3-character currency code. For example: USD, EUR.",
|
||||||
|
"default": "",
|
||||||
|
"secret": False,
|
||||||
|
"required": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "eventId",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Event ID",
|
||||||
|
"description": "ID of the event that triggered the conversion.",
|
||||||
|
"default": "{event.uuid}",
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "userIds",
|
||||||
|
"type": "dictionary",
|
||||||
|
"label": "User ids",
|
||||||
|
"description": "A map that contains user ids. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#idtype",
|
||||||
|
"default": {
|
||||||
|
"SHA256_EMAIL": "{sha256Hex(person.properties.email)}",
|
||||||
|
"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID": "{person.properties.li_fat_id ?? person.properties.$initial_li_fat_id}",
|
||||||
|
},
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "userInfo",
|
||||||
|
"type": "dictionary",
|
||||||
|
"label": "User information",
|
||||||
|
"description": "A map that contains user information data. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#userinfo",
|
||||||
|
"default": {
|
||||||
|
"firstName": "{person.properties.first_name}",
|
||||||
|
"lastName": "{person.properties.last_name}",
|
||||||
|
"title": "{person.properties.title}",
|
||||||
|
"companyName": "{person.properties.company}",
|
||||||
|
"countryCode": "{person.properties.$geoip_country_code}",
|
||||||
|
},
|
||||||
|
"secret": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
filters={
|
||||||
|
"events": [],
|
||||||
|
"actions": [],
|
||||||
|
"filter_test_accounts": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from inline_snapshot import snapshot
|
||||||
|
from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
|
||||||
|
from posthog.cdp.templates.linkedin_ads.template_linkedin_ads import (
|
||||||
|
template as template_linkedin_ads,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateLinkedInAds(BaseHogFunctionTemplateTest):
|
||||||
|
template = template_linkedin_ads
|
||||||
|
|
||||||
|
def _inputs(self, **kwargs):
|
||||||
|
inputs = {
|
||||||
|
"oauth": {
|
||||||
|
"access_token": "oauth-1234",
|
||||||
|
},
|
||||||
|
"accountId": "account-12345",
|
||||||
|
"conversionRuleId": "conversion-rule-12345",
|
||||||
|
"conversionDateTime": 1737464596570,
|
||||||
|
"conversionValue": "100",
|
||||||
|
"currencyCode": "USD",
|
||||||
|
"eventId": "event-12345",
|
||||||
|
"userIds": {
|
||||||
|
"SHA256_EMAIL": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
|
||||||
|
"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID": "abc",
|
||||||
|
},
|
||||||
|
"userInfo": {"lastName": "AI", "firstName": "Max", "companyName": "PostHog", "countryCode": "US"},
|
||||||
|
}
|
||||||
|
inputs.update(kwargs)
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
def test_function_works(self):
|
||||||
|
self.run_function(self._inputs())
|
||||||
|
assert self.get_mock_fetch_calls()[0] == snapshot(
|
||||||
|
(
|
||||||
|
"https://api.linkedin.com/rest/conversionEvents",
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer oauth-1234",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"LinkedIn-Version": "202409",
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"conversion": "urn:lla:llaPartnerConversion:conversion-rule-12345",
|
||||||
|
"conversionHappenedAt": 1737464596570,
|
||||||
|
"conversionValue": {"currencyCode": "USD", "amount": "100"},
|
||||||
|
"user": {
|
||||||
|
"userIds": [
|
||||||
|
{
|
||||||
|
"idType": "SHA256_EMAIL",
|
||||||
|
"idValue": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
|
||||||
|
},
|
||||||
|
{"idType": "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID", "idValue": "abc"},
|
||||||
|
],
|
||||||
|
"userInfo": {
|
||||||
|
"lastName": "AI",
|
||||||
|
"firstName": "Max",
|
||||||
|
"companyName": "PostHog",
|
||||||
|
"countryCode": "US",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"eventId": "event-12345",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
29
posthog/migrations/0558_alter_integration_kind.py
Normal file
29
posthog/migrations/0558_alter_integration_kind.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 4.2.18 on 2025-01-31 14:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("posthog", "0557_add_tags_to_experiment_saved_metrics"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="integration",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("slack", "Slack"),
|
||||||
|
("salesforce", "Salesforce"),
|
||||||
|
("hubspot", "Hubspot"),
|
||||||
|
("google-pubsub", "Google Pubsub"),
|
||||||
|
("google-cloud-storage", "Google Cloud Storage"),
|
||||||
|
("google-ads", "Google Ads"),
|
||||||
|
("snapchat", "Snapchat"),
|
||||||
|
("linkedin-ads", "Linkedin Ads"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1 +1 @@
|
|||||||
0557_add_tags_to_experiment_saved_metrics
|
0558_alter_integration_kind
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class Integration(models.Model):
|
|||||||
GOOGLE_CLOUD_STORAGE = "google-cloud-storage"
|
GOOGLE_CLOUD_STORAGE = "google-cloud-storage"
|
||||||
GOOGLE_ADS = "google-ads"
|
GOOGLE_ADS = "google-ads"
|
||||||
SNAPCHAT = "snapchat"
|
SNAPCHAT = "snapchat"
|
||||||
|
LINKEDIN_ADS = "linkedin-ads"
|
||||||
|
|
||||||
team = models.ForeignKey("Team", on_delete=models.CASCADE)
|
team = models.ForeignKey("Team", on_delete=models.CASCADE)
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ class OauthConfig:
|
|||||||
|
|
||||||
|
|
||||||
class OauthIntegration:
|
class OauthIntegration:
|
||||||
supported_kinds = ["slack", "salesforce", "hubspot", "google-ads", "snapchat"]
|
supported_kinds = ["slack", "salesforce", "hubspot", "google-ads", "snapchat", "linkedin-ads"]
|
||||||
integration: Integration
|
integration: Integration
|
||||||
|
|
||||||
def __init__(self, integration: Integration) -> None:
|
def __init__(self, integration: Integration) -> None:
|
||||||
@@ -210,6 +211,21 @@ class OauthIntegration:
|
|||||||
id_path="me.id",
|
id_path="me.id",
|
||||||
name_path="me.email",
|
name_path="me.email",
|
||||||
)
|
)
|
||||||
|
elif kind == "linkedin-ads":
|
||||||
|
if not settings.LINKEDIN_APP_CLIENT_ID or not settings.LINKEDIN_APP_CLIENT_SECRET:
|
||||||
|
raise NotImplementedError("LinkedIn Ads app not configured")
|
||||||
|
|
||||||
|
return OauthConfig(
|
||||||
|
authorize_url="https://www.linkedin.com/oauth/v2/authorization",
|
||||||
|
token_info_url="https://api.linkedin.com/v2/userinfo",
|
||||||
|
token_info_config_fields=["sub", "email"],
|
||||||
|
token_url="https://www.linkedin.com/oauth/v2/accessToken",
|
||||||
|
client_id=settings.LINKEDIN_APP_CLIENT_ID,
|
||||||
|
client_secret=settings.LINKEDIN_APP_CLIENT_SECRET,
|
||||||
|
scope="r_ads rw_conversions openid profile email",
|
||||||
|
id_path="sub",
|
||||||
|
name_path="email",
|
||||||
|
)
|
||||||
|
|
||||||
raise NotImplementedError(f"Oauth config for kind {kind} not implemented")
|
raise NotImplementedError(f"Oauth config for kind {kind} not implemented")
|
||||||
|
|
||||||
@@ -594,3 +610,43 @@ class GoogleCloudIntegration:
|
|||||||
reload_integrations_on_workers(self.integration.team_id, [self.integration.id])
|
reload_integrations_on_workers(self.integration.team_id, [self.integration.id])
|
||||||
|
|
||||||
logger.info(f"Refreshed access token for {self}")
|
logger.info(f"Refreshed access token for {self}")
|
||||||
|
|
||||||
|
|
||||||
|
class LinkedInAdsIntegration:
|
||||||
|
integration: Integration
|
||||||
|
|
||||||
|
def __init__(self, integration: Integration) -> None:
|
||||||
|
if integration.kind != "linkedin-ads":
|
||||||
|
raise Exception("LinkedInAdsIntegration init called with Integration with wrong 'kind'")
|
||||||
|
|
||||||
|
self.integration = integration
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> WebClient:
|
||||||
|
return WebClient(self.integration.sensitive_config["access_token"])
|
||||||
|
|
||||||
|
def list_linkedin_ads_conversion_rules(self, account_id) -> list[dict]:
|
||||||
|
response = requests.request(
|
||||||
|
"GET",
|
||||||
|
f"https://api.linkedin.com/rest/conversions?q=account&account=urn%3Ali%3AsponsoredAccount%3A{account_id}&fields=conversionMethod%2Cenabled%2Ctype%2Cname%2Cid%2Ccampaigns%2CattributionType",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.integration.sensitive_config['access_token']}",
|
||||||
|
"LinkedIn-Version": "202409",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def list_linkedin_ads_accounts(self) -> dict:
|
||||||
|
response = requests.request(
|
||||||
|
"GET",
|
||||||
|
"https://api.linkedin.com/v2/adAccountsV2?q=search",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {self.integration.sensitive_config['access_token']}",
|
||||||
|
"LinkedIn-Version": "202409",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ SNAPCHAT_APP_CLIENT_SECRET = get_from_env("SNAPCHAT_APP_CLIENT_SECRET", "")
|
|||||||
SALESFORCE_CONSUMER_KEY = get_from_env("SALESFORCE_CONSUMER_KEY", "")
|
SALESFORCE_CONSUMER_KEY = get_from_env("SALESFORCE_CONSUMER_KEY", "")
|
||||||
SALESFORCE_CONSUMER_SECRET = get_from_env("SALESFORCE_CONSUMER_SECRET", "")
|
SALESFORCE_CONSUMER_SECRET = get_from_env("SALESFORCE_CONSUMER_SECRET", "")
|
||||||
|
|
||||||
|
LINKEDIN_APP_CLIENT_ID = get_from_env("LINKEDIN_APP_CLIENT_ID", "")
|
||||||
|
LINKEDIN_APP_CLIENT_SECRET = get_from_env("LINKEDIN_APP_CLIENT_SECRET", "")
|
||||||
|
|
||||||
GOOGLE_ADS_APP_CLIENT_ID = get_from_env("GOOGLE_ADS_APP_CLIENT_ID", "")
|
GOOGLE_ADS_APP_CLIENT_ID = get_from_env("GOOGLE_ADS_APP_CLIENT_ID", "")
|
||||||
GOOGLE_ADS_APP_CLIENT_SECRET = get_from_env("GOOGLE_ADS_APP_CLIENT_SECRET", "")
|
GOOGLE_ADS_APP_CLIENT_SECRET = get_from_env("GOOGLE_ADS_APP_CLIENT_SECRET", "")
|
||||||
GOOGLE_ADS_DEVELOPER_TOKEN = get_from_env("GOOGLE_ADS_DEVELOPER_TOKEN", "")
|
GOOGLE_ADS_DEVELOPER_TOKEN = get_from_env("GOOGLE_ADS_DEVELOPER_TOKEN", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user