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        if let Ok(Some(existing)) = self
120            .link_repo
121            .find_link_by_source_and_target(params.source_page, params.target_url)
122            .await
123        {
124            return Ok(existing);
125        }
126
127        let campaign_id =
128            CampaignId::new(format!("internal_navigation_{}", Utc::now().date_naive()));
129
130        let utm_params = UtmParams {
131            source: Some(utm_defaults::SOURCE_INTERNAL.to_string()),
132            medium: Some(utm_defaults::MEDIUM_CONTENT.to_string()),
133            campaign: None,
134            term: None,
135            content: Some(params.source_content_id.to_string()),
136        };
137
138        self.generate_link(GenerateLinkParams {
139            target_url: params.target_url.to_string(),
140            link_type: LinkType::Utm,
141            campaign_id: Some(campaign_id),
142            campaign_name: Some("Internal Content Navigation".to_string()),
143            source_content_id: Some(params.source_content_id.clone()),
144            source_page: Some(params.source_page.to_string()),
145            utm_params: Some(utm_params),
146            link_text: params.link_text,
147            link_position: params.link_position,
148            expires_at: None,
149        })
150        .await
151    }
152
153    pub async fn generate_external_cta_link(
154        &self,
155        target_url: &str,
156        campaign_name: &str,
157        source_content_id: Option<ContentId>,
158        link_text: Option<String>,
159    ) -> Result<CampaignLink, ContentError> {
160        let campaign_id = CampaignId::new(format!("external_cta_{}", Utc::now().timestamp()));
161
162        let utm_params = UtmParams {
163            source: Some(utm_defaults::SOURCE_BLOG.to_string()),
164            medium: Some(utm_defaults::MEDIUM_CTA.to_string()),
165            campaign: Some(campaign_name.to_string()),
166            term: None,
167            content: source_content_id.as_ref().map(ToString::to_string),
168        };
169
170        self.generate_link(GenerateLinkParams {
171            target_url: target_url.to_string(),
172            link_type: LinkType::Both,
173            campaign_id: Some(campaign_id),
174            campaign_name: Some(campaign_name.to_string()),
175            source_content_id,
176            source_page: None,
177            utm_params: Some(utm_params),
178            link_text,
179            link_position: Some(utm_defaults::POSITION_CTA.to_string()),
180            expires_at: None,
181        })
182        .await
183    }
184
185    pub async fn generate_external_content_link(
186        &self,
187        params: GenerateContentLinkParams<'_>,
188    ) -> Result<CampaignLink, ContentError> {
189        let campaign_id = CampaignId::new(format!("social_share_{}", Utc::now().date_naive()));
190
191        self.generate_link(GenerateLinkParams {
192            target_url: params.target_url.to_string(),
193            link_type: LinkType::Redirect,
194            campaign_id: Some(campaign_id),
195            campaign_name: Some("Social Share".to_string()),
196            source_content_id: Some(params.source_content_id.clone()),
197            source_page: Some(params.source_page.to_string()),
198            utm_params: None,
199            link_text: params.link_text,
200            link_position: params.link_position,
201            expires_at: None,
202        })
203        .await
204    }
205
206    pub async fn get_link_by_short_code(
207        &self,
208        short_code: &str,
209    ) -> Result<Option<CampaignLink>, ContentError> {
210        Ok(self.link_repo.get_link_by_short_code(short_code).await?)
211    }
212
213    pub async fn get_link_by_id(
214        &self,
215        id: &systemprompt_identifiers::LinkId,
216    ) -> Result<Option<CampaignLink>, ContentError> {
217        Ok(self.link_repo.get_link_by_id(id).await?)
218    }
219
220    pub async fn delete_link(
221        &self,
222        id: &systemprompt_identifiers::LinkId,
223    ) -> Result<bool, ContentError> {
224        Ok(self.link_repo.delete_link(id).await?)
225    }
226
227    pub fn build_trackable_url(link: &CampaignLink, base_url: &str) -> String {
228        match link.link_type.as_str() {
229            "redirect" | "both" => {
230                format!("{}/r/{}", base_url, link.short_code)
231            },
232            _ => link.target_url.clone(),
233        }
234    }
235
236    pub fn inject_utm_params(url: &str, utm_params: &UtmParams) -> String {
237        let query_string = utm_params.to_query_string();
238        if query_string.is_empty() {
239            url.to_string()
240        } else {
241            let separator = if url.contains('?') { "&" } else { "?" };
242            format!("{url}{separator}{query_string}")
243        }
244    }
245
246    fn generate_short_code() -> String {
247        use rand::Rng;
248        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
249        const CODE_LENGTH: usize = 8;
250
251        let mut rng = rand::rng();
252        (0..CODE_LENGTH)
253            .map(|_| {
254                let idx = rng.random_range(0..CHARSET.len());
255                CHARSET[idx] as char
256            })
257            .collect()
258    }
259
260    fn determine_destination_type(url: &str) -> DestinationType {
261        if url.starts_with('/')
262            || url.starts_with("http://localhost")
263            || url.starts_with("https://localhost")
264            || url.contains("tyingshoelaces.com")
265            || url.contains("systemprompt.io")
266        {
267            DestinationType::Internal
268        } else {
269            DestinationType::External
270        }
271    }
272}