Skip to main content

systemprompt_content/repository/link/
mod.rs

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}