Skip to main content

systemprompt_content/repository/link/
mod.rs

1//! Campaign-link repository.
2//!
3//! [`LinkRepository`] manages `campaign_links` rows — creation (upsert by short
4//! code), lookup by short code, id, campaign, or source content, and deletion.
5//! Click recording and analytics live in the [`analytics`] submodule via
6//! [`LinkAnalyticsRepository`].
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11pub 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}