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