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}
17
18impl LinkRepository {
19 pub fn new(db: &DbPool) -> Result<Self, ContentError> {
20 let pool = db
21 .pool_arc()
22 .map_err(|e| ContentError::InvalidRequest(format!("Database pool error: {e}")))?;
23 Ok(Self { pool })
24 }
25
26 #[allow(clippy::cognitive_complexity)]
27 pub async fn create_link(
28 &self,
29 params: &CreateLinkParams,
30 ) -> Result<CampaignLink, sqlx::Error> {
31 let id = LinkId::generate();
32 let now = Utc::now();
33 sqlx::query_as!(
34 CampaignLink,
35 r#"
36 INSERT INTO campaign_links (
37 id, short_code, target_url, link_type, source_content_id, source_page,
38 campaign_id, campaign_name, utm_params, link_text, link_position,
39 destination_type, is_active, expires_at, created_at, updated_at
40 )
41 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
42 RETURNING id as "id: LinkId", short_code, target_url, link_type,
43 campaign_id as "campaign_id: CampaignId", campaign_name,
44 source_content_id as "source_content_id: ContentId", source_page,
45 utm_params, link_text, link_position, destination_type,
46 click_count, unique_click_count, conversion_count,
47 is_active, expires_at, created_at, updated_at
48 "#,
49 id.as_str(),
50 params.short_code,
51 params.target_url,
52 params.link_type,
53 params.source_content_id.as_ref().map(ContentId::as_str),
54 params.source_page,
55 params.campaign_id.as_ref().map(CampaignId::as_str),
56 params.campaign_name,
57 params.utm_params,
58 params.link_text,
59 params.link_position,
60 params.destination_type,
61 params.is_active,
62 params.expires_at,
63 now
64 )
65 .fetch_one(&*self.pool)
66 .await
67 }
68
69 pub async fn get_link_by_short_code(
70 &self,
71 short_code: &str,
72 ) -> Result<Option<CampaignLink>, sqlx::Error> {
73 sqlx::query_as!(
74 CampaignLink,
75 r#"
76 SELECT id as "id: LinkId", short_code, target_url, link_type,
77 campaign_id as "campaign_id: CampaignId", campaign_name,
78 source_content_id as "source_content_id: ContentId", source_page,
79 utm_params, link_text, link_position, destination_type,
80 click_count, unique_click_count, conversion_count,
81 is_active, expires_at, created_at, updated_at
82 FROM campaign_links
83 WHERE short_code = $1 AND is_active = true
84 "#,
85 short_code
86 )
87 .fetch_optional(&*self.pool)
88 .await
89 }
90
91 pub async fn list_links_by_campaign(
92 &self,
93 campaign_id: &CampaignId,
94 ) -> Result<Vec<CampaignLink>, sqlx::Error> {
95 sqlx::query_as!(
96 CampaignLink,
97 r#"
98 SELECT id as "id: LinkId", short_code, target_url, link_type,
99 campaign_id as "campaign_id: CampaignId", campaign_name,
100 source_content_id as "source_content_id: ContentId", source_page,
101 utm_params, link_text, link_position, destination_type,
102 click_count, unique_click_count, conversion_count,
103 is_active, expires_at, created_at, updated_at
104 FROM campaign_links
105 WHERE campaign_id = $1
106 ORDER BY created_at DESC
107 "#,
108 campaign_id.as_str()
109 )
110 .fetch_all(&*self.pool)
111 .await
112 }
113
114 pub async fn list_links_by_source_content(
115 &self,
116 content_id: &ContentId,
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 source_content_id = $1
129 ORDER BY created_at DESC
130 "#,
131 content_id.as_str()
132 )
133 .fetch_all(&*self.pool)
134 .await
135 }
136
137 pub async fn get_link_by_id(&self, id: &LinkId) -> Result<Option<CampaignLink>, sqlx::Error> {
138 sqlx::query_as!(
139 CampaignLink,
140 r#"
141 SELECT id as "id: LinkId", short_code, target_url, link_type,
142 campaign_id as "campaign_id: CampaignId", campaign_name,
143 source_content_id as "source_content_id: ContentId", source_page,
144 utm_params, link_text, link_position, destination_type,
145 click_count, unique_click_count, conversion_count,
146 is_active, expires_at, created_at, updated_at
147 FROM campaign_links
148 WHERE id = $1
149 "#,
150 id.as_str()
151 )
152 .fetch_optional(&*self.pool)
153 .await
154 }
155
156 pub async fn delete_link(&self, id: &LinkId) -> Result<bool, sqlx::Error> {
157 let result = sqlx::query!("DELETE FROM campaign_links WHERE id = $1", id.as_str())
158 .execute(&*self.pool)
159 .await?;
160 Ok(result.rows_affected() > 0)
161 }
162}