Salesforce stocke les activités sur deux objets : Tasks (appels, emails, tâches à faire) et Events (réunions, rendez-vous). Une timeline d’activité unifiée combine les deux en une seule vue chronologique en utilisant un pattern UNION et la résolution polymorphique pour identifier les entités auxquelles chaque activité se rapporte.
Pourquoi Tasks et Events sont séparés
Dans le modèle de données Salesforce, Tasks et Events représentent des types d’activité différents avec des schémas différents :
- Tasks ont
ActivityDate(une date, pas un timestamp),Status(Open, Completed, etc.) etPriority. Elles représentent des actions discrètes : journaliser un appel, envoyer un email, créer une tâche à faire. - Events ont
StartDateTimeetEndDateTime(timestamps),LocationetIsAllDayEvent. Ils représentent des activités limitées dans le temps : réunions, démos, visites sur site.
Les deux objets partagent les champs polymorphiques WhoId (référence à une personne) et WhatId (référence à un objet), plus des champs communs comme Subject, OwnerId et Description.
Le pattern UNION
Combiner les deux nécessite un mapping vers un ensemble de colonnes cohérent. La décision clé est de savoir comment normaliser les champs de date — Tasks ont une date, Events ont un datetime.
-- int__salesforce_unified_activity.sqlSELECT id AS activity__id, 'task' AS activity__source, subject AS activity__subject, activity_date AS activity__occurred_at, who_id AS activity__who_id, what_id AS activity__what_id, owner_id AS activity__owner_idFROM {{ ref('base__salesforce__task') }}
UNION ALL
SELECT id AS activity__id, 'event' AS activity__source, subject AS activity__subject, start_date_time AS activity__occurred_at, who_id AS activity__who_id, what_id AS activity__what_id, owner_id AS activity__owner_idFROM {{ ref('base__salesforce__event') }}La colonne activity__source préserve le type d’objet original pour que les consommateurs en aval puissent filtrer dessus si nécessaire (« montre-moi uniquement les réunions » = WHERE activity__source = 'event').
Quelques détails à noter :
activity_datevs.start_date_time— Tasks utilisent un champ DATE, Events un TIMESTAMP. Le UNION coerce la date Task vers un timestamp (minuit). Si la précision compte, ajoutezCAST(activity_date AS TIMESTAMP)explicitement pour être clair sur la conversion.- UNION ALL, pas UNION — il n’y a aucune raison de dédupliquer ici. Les Tasks et Events ont des IDs globalement uniques au sein de chaque objet, et aucun ID de Task ne correspondra à un ID d’Event (ils ont des patterns de préfixe différents).
- Alignement des colonnes — n’incluez que les colonnes qui existent sur les deux objets. Les champs spécifiques aux Tasks comme
Statusou les champs spécifiques aux Events commeEndDateTimeetLocationpeuvent être ajoutés comme colonnes nullable si nécessaire, mais gardez le UNION de base propre.
Couche de résolution polymorphique
Le modèle UNION ci-dessus vous donne une liste d’activités unifiées, mais activity__who_id et activity__what_id sont encore des IDs Salesforce opaques. Superposez le pattern de résolution polymorphique pour obtenir des références d’entités lisibles :
-- int__salesforce_activity_enriched.sqlWITH
activities AS ( SELECT * FROM {{ ref('int__salesforce_unified_activity') }}),
contacts AS ( SELECT contact__id, contact__full_name, contact__email FROM {{ ref('base__salesforce__contact') }}),
leads AS ( SELECT lead__id, lead__full_name, lead__email FROM {{ ref('base__salesforce__lead') }}),
accounts AS ( SELECT account__id, account__name FROM {{ ref('base__salesforce__account') }}),
opportunities AS ( SELECT opportunity__id, opportunity__name, opportunity__stage FROM {{ ref('base__salesforce__opportunity') }})
SELECT activities.activity__id, activities.activity__source, activities.activity__subject, activities.activity__occurred_at, activities.activity__owner_id,
-- Résolution Who CASE LEFT(activities.activity__who_id, 3) WHEN '003' THEN 'Contact' WHEN '00Q' THEN 'Lead' END AS activity__who_type, COALESCE( contacts.contact__full_name, leads.lead__full_name ) AS activity__who_name, COALESCE( contacts.contact__email, leads.lead__email ) AS activity__who_email,
-- Résolution What CASE LEFT(activities.activity__what_id, 3) WHEN '001' THEN 'Account' WHEN '006' THEN 'Opportunity' END AS activity__what_type, COALESCE( accounts.account__name, opportunities.opportunity__name ) AS activity__what_nameFROM activitiesLEFT JOIN contacts ON LEFT(activities.activity__who_id, 3) = '003' AND activities.activity__who_id = contacts.contact__idLEFT JOIN leads ON LEFT(activities.activity__who_id, 3) = '00Q' AND activities.activity__who_id = leads.lead__idLEFT JOIN accounts ON LEFT(activities.activity__what_id, 3) = '001' AND activities.activity__what_id = accounts.account__idLEFT JOIN opportunities ON LEFT(activities.activity__what_id, 3) = '006' AND activities.activity__what_id = opportunities.opportunity__idLa sortie est une timeline d’activité complète avec des références d’entités résolues : vous savez avec qui était l’activité (nom et email du contact ou du lead) et sur quoi elle portait (nom du compte ou nom et étape de l’opportunité).
Cas d’usage en aval
Dashboard d’activité par commercial
Groupez par activity__owner_id et activity__source pour voir le volume d’activité par commercial et type :
SELECT activity__owner_id, activity__source, COUNT(*) AS activity_count, COUNT(DISTINCT DATE(activity__occurred_at)) AS active_daysFROM {{ ref('int__salesforce_activity_enriched') }}WHERE activity__occurred_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)GROUP BY 1, 2Cela permet des décompositions du volume d’activité par commercial et type, et une corrélation avec la progression du pipeline.
Score d’engagement du compte
Comptez les activités par compte sur une fenêtre glissante pour un signal d’engagement :
SELECT activity__what_name AS account__name, COUNT(*) AS activities_30d, COUNT(DISTINCT activity__owner_id) AS reps_engaged, MAX(activity__occurred_at) AS last_activity_atFROM {{ ref('int__salesforce_activity_enriched') }}WHERE activity__what_type = 'Account' AND activity__occurred_at >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)GROUP BY 1La sortie met en évidence les comptes sans activité récente et les comptes avec plusieurs commerciaux impliqués.
Timeline d’activité d’une opportunité
Filtrez sur une opportunité spécifique pour voir l’historique complet des activités :
SELECT activity__occurred_at, activity__source, activity__subject, activity__who_name, activity__who_typeFROM {{ ref('int__salesforce_activity_enriched') }}WHERE activity__what_type = 'Opportunity' AND activities.activity__what_id = '006...'ORDER BY activity__occurred_atCela réplique la vue de timeline d’activité que les interfaces CRM fournissent nativement, pour utilisation dans les outils BI.
Matérialisation
Matérialisez le modèle UNION en vue ou modèle éphémère — c’est un simple UNION sans calcul. Matérialisez le modèle enrichi (avec les jointures polymorphiques) en table, puisqu’il implique plusieurs LEFT JOINs sur de grandes tables.
Pour les orgs Salesforce très actives (50 000+ activités par mois), envisagez une matérialisation incrémentale sur activity__occurred_at. Le UNION reste une vue, et le modèle enrichi devient incrémental, ne traitant que les nouvelles activités depuis la dernière exécution.
Le contraste HubSpot
HubSpot aussi track les engagements (appels, emails, réunions, notes, tâches), mais les stocke dans un seul objet engagement avec un champ type plutôt que de les diviser en objets séparés. Cela signifie que HubSpot n’a pas besoin de l’étape UNION — les données sont déjà unifiées dans la source. Cependant, les engagements HubSpot utilisent le modèle d’associations pour se lier aux contacts et aux deals, ce qui introduit sa propre complexité au niveau de la couche de jointure.
Les deux sources produisent une timeline unifiée avec des références d’entités résolues, via des patterns de jointure différents.