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