Skip to main content

systemprompt_content/services/link/
analytics.rs

1//! Link click and journey analytics queries.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use crate::error::ContentError;
7use crate::models::{
8    CampaignLink, CampaignPerformance, ContentJourneyNode, LinkClick, LinkPerformance,
9    RecordClickParams, TrackClickParams,
10};
11use crate::repository::{LinkAnalyticsRepository, LinkRepository};
12use chrono::Utc;
13use systemprompt_database::DbPool;
14use systemprompt_identifiers::{CampaignId, ContentId, LinkClickId, LinkId};
15
16const DEFAULT_JOURNEY_LIMIT: i64 = 50;
17const DEFAULT_CLICKS_LIMIT: i64 = 100;
18
19#[derive(Debug)]
20pub struct LinkAnalyticsService {
21    link_repo: LinkRepository,
22    analytics_repo: LinkAnalyticsRepository,
23}
24
25impl LinkAnalyticsService {
26    pub fn new(db: &DbPool) -> Result<Self, ContentError> {
27        Ok(Self {
28            link_repo: LinkRepository::new(db)?,
29            analytics_repo: LinkAnalyticsRepository::new(db)?,
30        })
31    }
32
33    pub async fn track_click(&self, params: &TrackClickParams) -> Result<LinkClick, ContentError> {
34        let is_first_click = !self
35            .analytics_repo
36            .check_session_clicked_link(&params.link_id, &params.session_id)
37            .await?;
38
39        let click_id = LinkClickId::generate();
40        let clicked_at = Utc::now();
41
42        let record_params = RecordClickParams::new(
43            click_id.clone(),
44            params.link_id.clone(),
45            params.session_id.clone(),
46            clicked_at,
47        )
48        .with_user_id(params.user_id.clone())
49        .with_context_id(params.context_id.clone())
50        .with_task_id(params.task_id.clone())
51        .with_referrer_page(params.referrer_page.clone())
52        .with_referrer_url(params.referrer_url.clone())
53        .with_user_agent(params.user_agent.clone())
54        .with_ip_address(params.ip_address.clone())
55        .with_device_type(params.device_type.clone())
56        .with_country(params.country.clone())
57        .with_is_first_click(is_first_click)
58        .with_is_conversion(false);
59
60        self.analytics_repo.record_click(&record_params).await?;
61
62        self.analytics_repo
63            .increment_link_clicks(&params.link_id, is_first_click)
64            .await?;
65
66        Ok(LinkClick {
67            id: click_id,
68            link_id: params.link_id.clone(),
69            session_id: params.session_id.clone(),
70            user_id: params.user_id.clone(),
71            context_id: params.context_id.clone(),
72            task_id: params.task_id.clone(),
73            referrer_page: params.referrer_page.clone(),
74            referrer_url: params.referrer_url.clone(),
75            clicked_at: Some(clicked_at),
76            user_agent: params.user_agent.clone(),
77            ip_address: params.ip_address.clone(),
78            device_type: params.device_type.clone(),
79            country: params.country.clone(),
80            is_first_click: Some(is_first_click),
81            is_conversion: Some(false),
82            conversion_at: None,
83            time_on_page_seconds: None,
84            scroll_depth_percent: None,
85        })
86    }
87
88    pub async fn get_link_performance(
89        &self,
90        link_id: &LinkId,
91    ) -> Result<Option<LinkPerformance>, ContentError> {
92        Ok(self.analytics_repo.get_link_performance(link_id).await?)
93    }
94
95    pub async fn get_campaign_performance(
96        &self,
97        campaign_id: &CampaignId,
98    ) -> Result<Option<CampaignPerformance>, ContentError> {
99        Ok(self
100            .analytics_repo
101            .get_campaign_performance(campaign_id)
102            .await?)
103    }
104
105    pub async fn get_content_journey_map(
106        &self,
107        limit: Option<i64>,
108        offset: Option<i64>,
109    ) -> Result<Vec<ContentJourneyNode>, ContentError> {
110        let limit = limit.unwrap_or(DEFAULT_JOURNEY_LIMIT);
111        let offset = offset.unwrap_or(0);
112        Ok(self
113            .analytics_repo
114            .get_content_journey_map(limit, offset)
115            .await?)
116    }
117
118    pub async fn get_link_clicks(
119        &self,
120        link_id: &LinkId,
121        limit: Option<i64>,
122        offset: Option<i64>,
123    ) -> Result<Vec<LinkClick>, ContentError> {
124        let limit = limit.unwrap_or(DEFAULT_CLICKS_LIMIT);
125        let offset = offset.unwrap_or(0);
126        Ok(self
127            .analytics_repo
128            .get_clicks_by_link(link_id, limit, offset)
129            .await?)
130    }
131
132    pub async fn get_links_by_campaign(
133        &self,
134        campaign_id: &CampaignId,
135    ) -> Result<Vec<CampaignLink>, ContentError> {
136        Ok(self.link_repo.list_links_by_campaign(campaign_id).await?)
137    }
138
139    pub async fn get_links_by_source_content(
140        &self,
141        source_content_id: &ContentId,
142    ) -> Result<Vec<CampaignLink>, ContentError> {
143        Ok(self
144            .link_repo
145            .list_links_by_source_content(source_content_id)
146            .await?)
147    }
148}