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