Skip to main content

systemprompt_content/services/link/
analytics.rs

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