1pub mod analytics;
2
3pub use analytics::LinkAnalyticsRepository;
4
5use crate::error::ContentError;
6use crate::models::{CampaignLink, CreateLinkParams};
7use chrono::Utc;
8use sqlx::PgPool;
9use std::sync::Arc;
10use systemprompt_database::DbPool;
11use systemprompt_identifiers::{CampaignId, ContentId, LinkId};
12
13#[derive(Debug)]
14pub struct LinkRepository {
15 pool: Arc<PgPool>,
16 write_pool: Arc<PgPool>,
17}
18
19impl LinkRepository {
20 pub fn new(db: &DbPool) -> Result<Self, ContentError> {
21 let pool = db
22 .pool_arc()
23 .map_err(|e| ContentError::InvalidRequest(format!("Database pool error: {e}")))?;
24 let write_pool = db
25 .write_pool_arc()
26 .map_err(|e| ContentError::InvalidRequest(format!("Database write pool error: {e}")))?;
27 Ok(Self { pool, write_pool })
28 }
29
30 #[allow(clippy::cognitive_complexity)]
31 pub async fn create_link(
32 &self,
33 params: &CreateLinkParams,
34 ) -> Result<CampaignLink, sqlx::Error> {
35 let id = LinkId::generate();
36 let now = Utc::now();
37 sqlx::query_as!(
38 CampaignLink,
39 r#"
40 INSERT INTO campaign_links (
41 id, short_code, target_url, link_type, source_content_id, source_page,
42 campaign_id, campaign_name, utm_params, link_text, link_position,
43 destination_type, is_active, expires_at, created_at, updated_at
44 )
45 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
46 ON CONFLICT (short_code) DO UPDATE SET
47 target_url = EXCLUDED.target_url,
48 link_type = EXCLUDED.link_type,
49 source_content_id = EXCLUDED.source_content_id,
50 source_page = EXCLUDED.source_page,
51 campaign_id = EXCLUDED.campaign_id,
52 campaign_name = EXCLUDED.campaign_name,
53 utm_params = EXCLUDED.utm_params,
54 link_text = EXCLUDED.link_text,
55 link_position = EXCLUDED.link_position,
56 destination_type = EXCLUDED.destination_type,
57 is_active = EXCLUDED.is_active,
58 expires_at = EXCLUDED.expires_at,
59 updated_at = EXCLUDED.updated_at
60 RETURNING id as "id: LinkId", short_code, target_url, link_type,
61 campaign_id as "campaign_id: CampaignId", campaign_name,
62 source_content_id as "source_content_id: ContentId", source_page,
63 utm_params, link_text, link_position, destination_type,
64 click_count, unique_click_count, conversion_count,
65 is_active, expires_at, created_at, updated_at
66 "#,
67 id.as_str(),
68 params.short_code,
69 params.target_url,
70 params.link_type,
71 params.source_content_id.as_ref().map(ContentId::as_str),
72 params.source_page,
73 params.campaign_id.as_ref().map(CampaignId::as_str),
74 params.campaign_name,
75 params.utm_params,
76 params.link_text,
77 params.link_position,
78 params.destination_type,
79 params.is_active,
80 params.expires_at,
81 now
82 )
83 .fetch_one(&*self.write_pool)
84 .await
85 }
86
87 pub async fn get_link_by_short_code(
88 &self,
89 short_code: &str,
90 ) -> Result<Option<CampaignLink>, sqlx::Error> {
91 sqlx::query_as!(
92 CampaignLink,
93 r#"
94 SELECT id as "id: LinkId", short_code, target_url, link_type,
95 campaign_id as "campaign_id: CampaignId", campaign_name,
96 source_content_id as "source_content_id: ContentId", source_page,
97 utm_params, link_text, link_position, destination_type,
98 click_count, unique_click_count, conversion_count,
99 is_active, expires_at, created_at, updated_at
100 FROM campaign_links
101 WHERE short_code = $1 AND is_active = true
102 "#,
103 short_code
104 )
105 .fetch_optional(&*self.pool)
106 .await
107 }
108
109 pub async fn list_links_by_campaign(
110 &self,
111 campaign_id: &CampaignId,
112 ) -> Result<Vec<CampaignLink>, sqlx::Error> {
113 sqlx::query_as!(
114 CampaignLink,
115 r#"
116 SELECT id as "id: LinkId", short_code, target_url, link_type,
117 campaign_id as "campaign_id: CampaignId", campaign_name,
118 source_content_id as "source_content_id: ContentId", source_page,
119 utm_params, link_text, link_position, destination_type,
120 click_count, unique_click_count, conversion_count,
121 is_active, expires_at, created_at, updated_at
122 FROM campaign_links
123 WHERE campaign_id = $1
124 ORDER BY created_at DESC
125 "#,
126 campaign_id.as_str()
127 )
128 .fetch_all(&*self.pool)
129 .await
130 }
131
132 pub async fn list_links_by_source_content(
133 &self,
134 content_id: &ContentId,
135 ) -> Result<Vec<CampaignLink>, sqlx::Error> {
136 sqlx::query_as!(
137 CampaignLink,
138 r#"
139 SELECT id as "id: LinkId", short_code, target_url, link_type,
140 campaign_id as "campaign_id: CampaignId", campaign_name,
141 source_content_id as "source_content_id: ContentId", source_page,
142 utm_params, link_text, link_position, destination_type,
143 click_count, unique_click_count, conversion_count,
144 is_active, expires_at, created_at, updated_at
145 FROM campaign_links
146 WHERE source_content_id = $1
147 ORDER BY created_at DESC
148 "#,
149 content_id.as_str()
150 )
151 .fetch_all(&*self.pool)
152 .await
153 }
154
155 pub async fn get_link_by_id(&self, id: &LinkId) -> Result<Option<CampaignLink>, sqlx::Error> {
156 sqlx::query_as!(
157 CampaignLink,
158 r#"
159 SELECT id as "id: LinkId", short_code, target_url, link_type,
160 campaign_id as "campaign_id: CampaignId", campaign_name,
161 source_content_id as "source_content_id: ContentId", source_page,
162 utm_params, link_text, link_position, destination_type,
163 click_count, unique_click_count, conversion_count,
164 is_active, expires_at, created_at, updated_at
165 FROM campaign_links
166 WHERE id = $1
167 "#,
168 id.as_str()
169 )
170 .fetch_optional(&*self.pool)
171 .await
172 }
173
174 pub async fn find_link_by_source_and_target(
175 &self,
176 source_page: &str,
177 target_url: &str,
178 ) -> Result<Option<CampaignLink>, sqlx::Error> {
179 sqlx::query_as!(
180 CampaignLink,
181 r#"
182 SELECT id as "id: LinkId", short_code, target_url, link_type,
183 campaign_id as "campaign_id: CampaignId", campaign_name,
184 source_content_id as "source_content_id: ContentId", source_page,
185 utm_params, link_text, link_position, destination_type,
186 click_count, unique_click_count, conversion_count,
187 is_active, expires_at, created_at, updated_at
188 FROM campaign_links
189 WHERE source_page = $1 AND target_url = $2 AND is_active = true
190 ORDER BY created_at DESC
191 LIMIT 1
192 "#,
193 source_page,
194 target_url
195 )
196 .fetch_optional(&*self.pool)
197 .await
198 }
199
200 pub async fn delete_link(&self, id: &LinkId) -> Result<bool, sqlx::Error> {
201 let result = sqlx::query!("DELETE FROM campaign_links WHERE id = $1", id.as_str())
202 .execute(&*self.write_pool)
203 .await?;
204 Ok(result.rows_affected() > 0)
205 }
206}