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 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}