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