Skip to main content

systemprompt_content/models/
link.rs

1//! Campaign-link and click-tracking data types.
2//!
3//! Defines the stored [`CampaignLink`] and [`LinkClick`] rows, the
4//! [`LinkType`] / [`DestinationType`] classifiers, [`UtmParams`] with its query
5//! serialisation, and the aggregate views [`LinkPerformance`],
6//! [`CampaignPerformance`], and [`ContentJourneyNode`].
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use sqlx::FromRow;
14use systemprompt_identifiers::{
15    CampaignId, ContentId, ContextId, LinkClickId, LinkId, SessionId, TaskId, UserId,
16};
17
18#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
19pub struct CampaignLink {
20    pub id: LinkId,
21    pub short_code: String,
22    pub target_url: String,
23    pub link_type: String,
24    pub campaign_id: Option<CampaignId>,
25    pub campaign_name: Option<String>,
26    pub source_content_id: Option<ContentId>,
27    pub source_page: Option<String>,
28    pub utm_params: Option<String>,
29    pub link_text: Option<String>,
30    pub link_position: Option<String>,
31    pub destination_type: Option<String>,
32    pub click_count: Option<i32>,
33    pub unique_click_count: Option<i32>,
34    pub conversion_count: Option<i32>,
35    pub is_active: Option<bool>,
36    pub expires_at: Option<DateTime<Utc>>,
37    pub created_at: Option<DateTime<Utc>>,
38    pub updated_at: Option<DateTime<Utc>>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
42pub struct LinkClick {
43    pub id: LinkClickId,
44    pub link_id: LinkId,
45    pub session_id: SessionId,
46    pub user_id: Option<UserId>,
47    pub context_id: Option<ContextId>,
48    pub task_id: Option<TaskId>,
49    pub referrer_page: Option<String>,
50    pub referrer_url: Option<String>,
51    pub clicked_at: Option<DateTime<Utc>>,
52    pub user_agent: Option<String>,
53    pub ip_address: Option<String>,
54    pub device_type: Option<String>,
55    pub country: Option<String>,
56    pub is_first_click: Option<bool>,
57    pub is_conversion: Option<bool>,
58    pub conversion_at: Option<DateTime<Utc>>,
59    pub time_on_page_seconds: Option<i32>,
60    pub scroll_depth_percent: Option<i32>,
61}
62
63#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
64pub enum LinkType {
65    Redirect,
66    Utm,
67    Both,
68}
69
70impl LinkType {
71    pub const fn as_str(&self) -> &'static str {
72        match self {
73            Self::Redirect => "redirect",
74            Self::Utm => "utm",
75            Self::Both => "both",
76        }
77    }
78}
79
80impl std::fmt::Display for LinkType {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "{}", self.as_str())
83    }
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
87pub enum DestinationType {
88    Internal,
89    External,
90}
91
92impl DestinationType {
93    pub const fn as_str(&self) -> &'static str {
94        match self {
95            Self::Internal => "internal",
96            Self::External => "external",
97        }
98    }
99}
100
101impl std::fmt::Display for DestinationType {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        write!(f, "{}", self.as_str())
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct UtmParams {
109    pub source: Option<String>,
110    pub medium: Option<String>,
111    pub campaign: Option<String>,
112    pub term: Option<String>,
113    pub content: Option<String>,
114}
115
116impl UtmParams {
117    pub fn to_query_string(&self) -> String {
118        let mut parts = Vec::new();
119        if let Some(ref source) = self.source {
120            parts.push(format!("utm_source={source}"));
121        }
122        if let Some(ref medium) = self.medium {
123            parts.push(format!("utm_medium={medium}"));
124        }
125        if let Some(ref campaign) = self.campaign {
126            parts.push(format!("utm_campaign={campaign}"));
127        }
128        if let Some(ref term) = self.term {
129            parts.push(format!("utm_term={term}"));
130        }
131        if let Some(ref content) = self.content {
132            parts.push(format!("utm_content={content}"));
133        }
134        parts.join("&")
135    }
136
137    pub fn to_json(&self) -> Result<String, serde_json::Error> {
138        serde_json::to_string(self)
139    }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
143pub struct LinkPerformance {
144    pub link_id: LinkId,
145    pub click_count: i64,
146    pub unique_click_count: i64,
147    pub conversion_count: i64,
148    pub conversion_rate: Option<f64>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
152pub struct CampaignPerformance {
153    pub campaign_id: CampaignId,
154    pub total_clicks: i64,
155    pub link_count: i64,
156    pub unique_visitors: Option<i64>,
157    pub conversion_count: Option<i64>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ContentJourneyNode {
162    pub source_content_id: ContentId,
163    pub target_url: String,
164    pub click_count: i32,
165}
166
167impl CampaignLink {
168    pub fn get_full_url(&self) -> String {
169        if let Some(ref params_json) = self.utm_params
170            && let Ok(params) = serde_json::from_str::<UtmParams>(params_json)
171        {
172            let query = params.to_query_string();
173            if !query.is_empty() {
174                let separator = if self.target_url.contains('?') {
175                    "&"
176                } else {
177                    "?"
178                };
179                return format!("{}{}{}", self.target_url, separator, query);
180            }
181        }
182        self.target_url.clone()
183    }
184}