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