Skip to main content

systemprompt_content/services/link/
generation.rs

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