1-- zoho_crm_rep_activity - calls, meetings and tasks logged per rep per day.
2-- Grain: day x owner.
3-- Depends on: staging.zoho_crm.call, .event, .task, .user
4--
5-- READ THIS BEFORE USING IT. This model counts activity per REP, never per deal
6 , -- account, contact or lead. That is not a design choice - Zoho's Calls, Events and
7-- Tasks modules all carry What_Id / Who_Id pointing at the record the activity
8-- belongs to, and the Weld connector does not sync either field. owner_id is the
9-- only foreign key on all three streams.
10--
11-- So this model answers "how much is each rep doing" and cannot answer "how much
12-- activity did we put into the deals we won". Any dashboard promising
13-- activity-to-outcome attribution from this connector alone is inventing the link.
14-- Until the connector syncs the parent ids, the join has to come from somewhere
15-- else - a calendar or dialer source keyed on email, or Zoho's Notes module
16 , -- whose parent id IS synced (parent_id_id in raw, parent_id after staging).
17--
18-- WHY THERE IS NO tasks_completed COLUMN: the connector syncs is_completed but no
19-- completion timestamp. Completions can be counted as a current-state total, never
20-- placed on a day. Booking them on modified_time would move a task's completion
21-- every time anyone edited it afterwards.
22with
23 activity as (
24 select
25 zoho_org
26 , owner_id
27 , date(call_start_time) as activity_date
28 , 1 as calls_logged
29 , call_duration_minutes as call_minutes
30 , 0 as events_held
31 , 0 as event_minutes
32 , 0 as tasks_created
33 from
34 {{staging.zoho_crm.call}}
35 where
36 call_start_time is not null
37 union all
38 select
39 zoho_org
40 , owner_id
41 , date(start_date_time) as activity_date
42 , 0
43 , 0
44 , 1 as events_held
45 , duration_minutes as event_minutes
46 , 0
47 from
48 {{staging.zoho_crm.event}}
49 where
50 start_date_time is not null
51 union all
52 -- Tasks land on the day they were CREATED, not their due date. Due dates get
53 -- pushed; creation is when the rep actually did something.
54 select
55 zoho_org
56 , owner_id
57 , date(created_time) as activity_date
58 , 0
59 , 0
60 , 0
61 , 0
62 , 1 as tasks_created
63 from
64 {{staging.zoho_crm.task}}
65 where
66 created_time is not null
67 )
68 , rolled_up as (
69 select
70 activity_date
71 , zoho_org
72 , owner_id
73 , sum(calls_logged) as calls_logged
74 , sum(call_minutes) as call_minutes
75 , sum(events_held) as events_held
76 , sum(event_minutes) as event_minutes
77 , sum(tasks_created) as tasks_created
78 from
79 activity
80 group by
81 1
82 , 2
83 , 3
84 )
85 , date_spine as (
86 select
87 day as activity_date
88 from
89 unnest (
90 generate_date_array(
91 (
92 select
93 min(activity_date)
94 from
95 rolled_up
96 )
97 , current_date()
98 , interval 1 day
99 )
100 ) as day
101 )
102 , owners as (
103 -- Reps who have logged anything at all. Spining every Zoho user against every
104 -- day would bury the active team under admin and integration logins.
105 select distinct
106 zoho_org
107 , owner_id
108 from
109 rolled_up
110 )
111 , grid as (
112 select
113 s.activity_date
114 , o.zoho_org
115 , o.owner_id
116 from
117 date_spine s
118 cross join owners o
119 )
120select
121 g.activity_date
122 , g.zoho_org
123 , g.owner_id
124 , u.full_name as owner_name
125 , u.email as owner_email
126 , u.role_name as owner_role
127 , u.is_active as owner_is_active
128 , coalesce(r.calls_logged, 0) as calls_logged
129 , -- NULL rather than 0 when no call was logged: a zero here would drag down any
130 -- average-call-length metric computed over the column.
131 r.call_minutes
132 , coalesce(r.events_held, 0) as events_held
133 , r.event_minutes
134 , coalesce(r.tasks_created, 0) as tasks_created
135 , coalesce(r.calls_logged, 0) + coalesce(r.events_held, 0) + coalesce(r.tasks_created, 0) as total_activities
136 , -- Touchpoints that involved a human on the other end, which is usually the
137 -- number a sales lead actually wants out of an activity report.
138 coalesce(r.calls_logged, 0) + coalesce(r.events_held, 0) as live_touchpoints
139from
140 grid g
141 left join rolled_up r on r.activity_date = g.activity_date
142 and r.zoho_org = g.zoho_org
143 and r.owner_id = g.owner_id
144 left join {{staging.zoho_crm.user}} u on u.zoho_org = g.zoho_org
145 and u.user_id = g.owner_id
146order by
147 g.activity_date
148 , owner_nameRequired models (5)
This model reads from staging models rather than raw tables. Deploy these first — Weld resolves the order for you.
staging.zoho_crm.callCalls, with Zoho's duration string parsed. No parent record key exists.
-- staging.zoho_crm.call
-- Thin wrapper over raw `call`. Casts, renames, parses the duration string.
--
-- NO PARENT LINK. Same as task: Zoho's Calls module has What_Id / Who_Id, the
-- connector does not sync them. owner_id is the only key.
--
-- DURATION IS A STRING, NOT A NUMBER. `call_duration` arrives as text with one
-- colon. Zoho's API reference documents the field as hh:mm; the CRM UI shows
-- mm:ss for short calls, and there is no Call_Duration_in_seconds column on this
-- stream to disambiguate. This model parses it as the DOCUMENTED hh:mm.
--
-- Reconcile call_duration_minutes against one call of known length before
-- reporting on it. If your org turns out to emit mm:ss, divide by 60.
-- tests/assert_call_duration_parses.sql guards the shape, not the unit.
select
'org_1' as zoho_org
, cast(id as string) as call_id
, nullif(trim(cast(subject as string)), '') as subject
, nullif(trim(cast(call_type as string)), '') as call_type
, cast(call_start_time as timestamp) as call_start_time
, cast(call_duration as string) as call_duration_raw
, case
when regexp_contains(
cast(call_duration as string)
, r'^\s*\d+:\d{1,2}\s*$'
) then safe_cast(
split(trim(cast(call_duration as string)), ':') [offset(0)] as int64
) * 60 + safe_cast(
split(trim(cast(call_duration as string)), ':') [offset(1)] as int64
)
end as call_duration_minutes
, cast(owner_id as string) as owner_id
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.call}}staging.zoho_crm.eventMeetings, with duration computed from both timestamps.
-- staging.zoho_crm.event
-- Thin wrapper over raw `event` (Zoho's Meetings module). Casts, renames, derives
-- duration from the start and end timestamps.
--
-- NO PARENT LINK. Same as task and call: owner_id is the only foreign key.
select
'org_1' as zoho_org
, cast(id as string) as event_id
, nullif(trim(cast(event_title as string)), '') as event_title
, cast(start_date_time as timestamp) as start_date_time
, cast(end_date_time as timestamp) as end_date_time
, -- Unlike call, event carries both ends, so duration is computed rather than
-- parsed out of a string and the unit is unambiguous.
timestamp_diff(
cast(end_date_time as timestamp)
, cast(start_date_time as timestamp)
, minute
) as duration_minutes
, nullif(trim(cast(location as string)), '') as location
, cast(owner_id as string) as owner_id
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.event}}staging.zoho_crm.taskTasks with is_completed derived. No parent record key exists.
-- staging.zoho_crm.task
-- Thin wrapper over raw `task`. Casts, renames, derives is_completed.
--
-- NO PARENT LINK. Zoho's Tasks module has What_Id / Who_Id (the deal, account
, -- contact or lead the task hangs off), but the connector does not sync them. The
-- only foreign key on this stream is owner_id, so a task can be counted per rep
-- and per day and nothing else. Do not join it to a deal - there is no key to
-- join on.
select
'org_1' as zoho_org
, cast(id as string) as task_id
, nullif(trim(cast(subject as string)), '') as subject
, nullif(trim(cast(status as string)), '') as status
, nullif(trim(cast(priority as string)), '') as priority
, cast(due_date as date) as due_date
, -- Zoho's default Task statuses are Not Started, Deferred, In Progress
, -- Completed and Waiting for input. Matched on a pattern rather than equality
-- so a renamed or translated status does not silently read as incomplete.
lower(cast(status as string)) like '%complet%' as is_completed
, cast(owner_id as string) as owner_id
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.task}}staging.zoho_crm.userThe only dimension the other modules can join to. Derives is_active.
-- staging.zoho_crm.user
-- Thin wrapper over raw `user`. Casts, renames, derives is_active. No other logic.
--
-- This is the only dimension every other stream can join to: every record-owning
-- module carries owner_id, and nothing else in the Zoho schema carries a foreign
-- key. Sync this stream even if you think you do not need it.
--
-- Single Zoho org. To add another, UNION ALL a second block below pointing at that
-- org's connector with a different zoho_org label. Keep it in staging so the core
-- models never have to know how many orgs there are.
select
'org_1' as zoho_org
, cast(id as string) as user_id
, nullif(trim(cast(full_name as string)), '') as full_name
, cast(first_name as string) as first_name
, cast(last_name as string) as last_name
, lower(nullif(trim(cast(email as string)), '')) as email
, cast(status as string) as status
, -- Zoho keeps deactivated users in the module rather than deleting them, so
-- filtering on this is how you get "reps who could take a deal today".
lower(cast(status as string)) = 'active' as is_active
, cast(role_name as string) as role_name
, cast(profile_name as string) as profile_name
, cast(created_time as timestamp) as created_time
, cast(modified_time as timestamp) as modified_time
from
{{raw.zoho_crm.user}}analytics.zoho_crm.rep_activityThin BI-facing contract over the core model - bind dashboards here, not to core.
-- analytics.zoho_crm.rep_activity
-- BI-facing contract over the core model. See analytics/deal_pipeline.sql for why
-- this layer exists even when it is a passthrough.
--
-- Reminder for whoever binds a dashboard to this: activity here is per rep only.
-- There is no deal or account key on Zoho's call, task and event streams in this
-- connector, so do not label a tile "activity per opportunity".
select
*
from
{{core.zoho_crm.rep_activity}}Example output
+ ---------------+--------------+--------------+--------------+-------------+---------------+---------------+------------------+------------------+
| activity_date | owner_name | calls_logged | call_minutes | events_held | event_minutes | tasks_created | total_activities | live_touchpoints | + ---------------+--------------+--------------+--------------+-------------+---------------+---------------+------------------+------------------+
| 2026 -08 -24 | Amara Okafor | 11 | 148 | 3 | 90 | 7 | 21 | 14 | | 2026 -08 -24 | Jonas Beck | 6 | 71 | 2 | 60 | 4 | 12 | 8 | | 2026 -08 -25 | Amara Okafor | 9 | 112 | 4 | 150 | 5 | 18 | 13 | | 2026 -08 -25 | Jonas Beck | 0 | | 1 | 30 | 2 | 3 | 1 | | 2026 -08 -26 | Amara Okafor | 13 | 186 | 2 | 55 | 9 | 24 | 15 | + ---------------+--------------+--------------+--------------+-------------+---------------+---------------+------------------+------------------+Counts calls, meetings and tasks per rep per day on a date spine, with call and meeting minutes and a live-touchpoints total that ignores tasks. Read the scope note before building a dashboard on it: Zoho's Calls, Events and Tasks modules each carry a What_Id and Who_Id pointing at the deal, account, contact or lead the activity belongs to, and neither field is synced by this connector. owner_id is the only foreign key on all three streams, so this model answers how much each rep is doing and cannot answer how much activity went into the deals you won. There is also no tasks_completed column, because the connector syncs the completion flag but no completion timestamp - completions can be totalled, never placed on a day.