Skip to main content

systemprompt_content/services/link/
generation.rs

1//! Trackable link generation service.
2//!
3//! [`LinkGenerationService`] mints campaign links for the supported channels
4//! (social, internal navigation, external CTA, external share), assembling the
5//! UTM parameters and short code for each, and resolves destination type and
6//! the public trackable URL.
7
8use crate::error::ContentError;
9use crate::models::{CampaignLink, CreateLinkParams, DestinationType, LinkType, UtmParams};
10use crate::repository::LinkRepository;
11use chrono::{DateTime, Utc};
12use systemprompt_database::DbPool;
13use systemprompt_identifiers::{CampaignId, ContentId};
14
15mod utm_defaults {
16    pub(super) const MEDIUM_SOCIAL: &str = "social";
17    pub(super) const SOURCE_INTERNAL: &str = "internal";
18    pub(super) const MEDIUM_CONTENT: &str = "content";
19    pub(super) const SOURCE_BLOG: &str = "blog";
20    pub(super) const MEDIUM_CTA: &str = "cta";
21    pub(super) const POSITION_CTA: &str = "cta";
22}
23
24#[derive(Debug)]
25pub struct GenerateLinkParams {
26    pub target_url: String,
27    pub link_type: LinkType,
28    pub campaign_id: Option<CampaignId>,
29    pub campaign_name: Option<String>,
30    pub source_content_id: Option<ContentId>,
31    pub source_page: Option<String>,
32    pub utm_params: Option<UtmParams>,
33    pub link_text: Option<String>,
34    pub link_position: Option<String>,
35    pub expires_at: Option<DateTime<Utc>>,
36}
37
38#[derive(Debug)]
39pub struct GenerateContentLinkParams<'a> {
40    pub target_url: &'a str,
41    pub source_content_id: &'a ContentId,
42    pub source_page: &'a str,
43    pub link_text: Option<String>,
44    pub link_position: Option<String>,
45}
46
47#[derive(Debug)]
48pub struct LinkGenerationService {
49    link_repo: LinkRepository,
50}
51
52impl LinkGenerationService {
53    pub fn new(db: &DbPool) -> Result<Self, ContentError> {
54        Ok(Self {
55            link_repo: LinkRepository::new(db)?,
56        })
57    }
58
59    pub async fn generate_link(
60        &self,
61        params: GenerateLinkParams,
62    ) -> Result<CampaignLink, ContentError> {
63        let short_code = Self::generate_short_code();
64        let destination_type = Self::determine_destination_type(&params.target_url);
65
66        let utm_json = params
67            .utm_params
68            .as_ref()
69            .map(UtmParams::to_json)
70            .transpose()?;
71
72        let create_params =
73            CreateLinkParams::new(short_code, params.target_url, params.link_type.to_string())
74                .with_source_content_id(params.source_content_id)
75                .with_source_page(params.source_page)
76                .with_campaign_id(params.campaign_id)
77                .with_campaign_name(params.campaign_name)
78                .with_utm_params(utm_json)
79                .with_link_text(params.link_text)
80                .with_link_position(params.link_position)
81                .with_destination_type(Some(destination_type.to_string()))
82                .with_expires_at(params.expires_at);
83
84        let link = self.link_repo.create_link(&create_params).await?;
85
86        Ok(link)
87    }
88
89    pub async fn generate_social_media_link(
90        &self,
91        target_url: &str,
92        platform: &str,
93        campaign_name: &str,
94        source_content_id: Option<ContentId>,
95    ) -> Result<CampaignLink, ContentError> {
96        let campaign_id =
97            CampaignId::new(format!("social_{}_{}", platform, Utc::now().timestamp()));
98
99        let utm_params = UtmParams {
100            source: Some(platform.to_owned()),
101            medium: Some(utm_defaults::MEDIUM_SOCIAL.to_owned()),
102            campaign: Some(campaign_name.to_owned()),
103            term: None,
104            content: source_content_id.as_ref().map(ToString::to_string),
105        };
106
107        self.generate_link(GenerateLinkParams {
108            target_url: target_url.to_owned(),
109            link_type: LinkType::Both,
110            campaign_id: Some(campaign_id),
111            campaign_name: Some(campaign_name.to_owned()),
112            source_content_id,
113            source_page: None,
114            utm_params: Some(utm_params),
115            link_text: None,
116            link_position: None,
117            expires_at: None,
118        })
119        .await
120    }
121
122    pub async fn generate_internal_content_link(
123        &self,
124        params: GenerateContentLinkParams<'_>,
125    ) -> Result<CampaignLink, ContentError> {
126        if let Ok(Some(existing)) = self
127            .link_repo
128            .find_link_by_source_and_target(params.source_page, params.target_url)
129            .await
130        {
131            return Ok(existing);
132        }
133
134        let campaign_id =
135            CampaignId::new(format!("internal_navigation_{}", Utc::now().date_naive()));
136
137        let utm_params = UtmParams {
138            source: Some(utm_defaults::SOURCE_INTERNAL.to_owned()),
139            medium: Some(utm_defaults::MEDIUM_CONTENT.to_owned()),
140            campaign: None,
141            term: None,
142            content: Some(params.source_content_id.to_string()),
143        };
144
145        self.generate_link(GenerateLinkParams {
146            target_url: params.target_url.to_owned(),
147            link_type: LinkType::Utm,
148            campaign_id: Some(campaign_id),
149            campaign_name: Some("Internal Content Navigation".to_owned()),
150            source_content_id: Some(params.source_content_id.clone()),
151            source_page: Some(params.source_page.to_owned()),
152            utm_params: Some(utm_params),
153            link_text: params.link_text,
154            link_position: params.link_position,
155            expires_at: None,
156        })
157        .await
158    }
159
160    pub async fn generate_external_cta_link(
161        &self,
162        target_url: &str,
163        campaign_name: &str,
164        source_content_id: Option<ContentId>,
165        link_text: Option<String>,
166    ) -> Result<CampaignLink, ContentError> {
167        let campaign_id = CampaignId::new(format!("external_cta_{}", Utc::now().timestamp()));
168
169        let utm_params = UtmParams {
170            source: Some(utm_defaults::SOURCE_BLOG.to_owned()),
171            medium: Some(utm_defaults::MEDIUM_CTA.to_owned()),
172            campaign: Some(campaign_name.to_owned()),
173            term: None,
174            content: source_content_id.as_ref().map(ToString::to_string),
175        };
176
177        self.generate_link(GenerateLinkParams {
178            target_url: target_url.to_owned(),
179            link_type: LinkType::Both,
180            campaign_id: Some(campaign_id),
181            campaign_name: Some(campaign_name.to_owned()),
182            source_content_id,
183            source_page: None,
184            utm_params: Some(utm_params),
185            link_text,
186            link_position: Some(utm_defaults::POSITION_CTA.to_owned()),
187            expires_at: None,
188        })
189        .await
190    }
191
192    pub async fn generate_external_content_link(
193        &self,
194        params: GenerateContentLinkParams<'_>,
195    ) -> Result<CampaignLink, ContentError> {
196        let campaign_id = CampaignId::new(format!("social_share_{}", Utc::now().date_naive()));
197
198        self.generate_link(GenerateLinkParams {
199            target_url: params.target_url.to_owned(),
200            link_type: LinkType::Redirect,
201            campaign_id: Some(campaign_id),
202            campaign_name: Some("Social Share".to_owned()),
203            source_content_id: Some(params.source_content_id.clone()),
204            source_page: Some(params.source_page.to_owned()),
205            utm_params: None,
206            link_text: params.link_text,
207            link_position: params.link_position,
208            expires_at: None,
209        })
210        .await
211    }
212
213    pub async fn get_link_by_short_code(
214        &self,
215        short_code: &str,
216    ) -> Result<Option<CampaignLink>, ContentError> {
217        Ok(self.link_repo.get_link_by_short_code(short_code).await?)
218    }
219
220    pub async fn get_link_by_id(
221        &self,
222        id: &systemprompt_identifiers::LinkId,
223    ) -> Result<Option<CampaignLink>, ContentError> {
224        Ok(self.link_repo.get_link_by_id(id).await?)
225    }
226
227    pub async fn delete_link(
228        &self,
229        id: &systemprompt_identifiers::LinkId,
230    ) -> Result<bool, ContentError> {
231        Ok(self.link_repo.delete_link(id).await?)
232    }
233
234    pub fn build_trackable_url(link: &CampaignLink, base_url: &str) -> String {
235        match link.link_type.as_str() {
236            "redirect" | "both" => {
237                format!("{}/r/{}", base_url, link.short_code)
238            },
239            _ => link.target_url.clone(),
240        }
241    }
242
243    pub fn inject_utm_params(url: &str, utm_params: &UtmParams) -> String {
244        let query_string = utm_params.to_query_string();
245        if query_string.is_empty() {
246            url.to_owned()
247        } else {
248            let separator = if url.contains('?') { "&" } else { "?" };
249            format!("{url}{separator}{query_string}")
250        }
251    }
252
253    fn generate_short_code() -> String {
254        use rand::RngExt;
255        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
256        const CODE_LENGTH: usize = 8;
257
258        let mut rng = rand::rng();
259        (0..CODE_LENGTH)
260            .map(|_| {
261                let idx = rng.random_range(0..CHARSET.len());
262                CHARSET[idx] as char
263            })
264            .collect()
265    }
266
267    fn determine_destination_type(url: &str) -> DestinationType {
268        if url.starts_with('/')
269            || url.starts_with("http://localhost")
270            || url.starts_with("https://localhost")
271            || url.contains("tyingshoelaces.com")
272            || url.contains("systemprompt.io")
273        {
274            DestinationType::Internal
275        } else {
276            DestinationType::External
277        }
278    }
279}