systemprompt_content/services/link/
generation.rs1use 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(¶ms.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}