Skip to main content

systemprompt_users/repository/user/
merge.rs

1//! Anonymous-to-identified user merge operations.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use sqlx::{Acquire, Postgres, Transaction};
7use systemprompt_identifiers::UserId;
8
9use crate::error::Result;
10use crate::repository::UserRepository;
11
12#[derive(Debug, Clone, Copy)]
13pub struct MergeResult {
14    pub sessions: u64,
15    pub tasks: u64,
16    pub total_rows: u64,
17}
18
19// Why: `merge_users` transfers data rows only. Rows in these credential and
20// auth-state tables are bound to the source identity and die with it via FK
21// CASCADE when the source row is deleted. A new table holding credentials or
22// auth state belongs on this list, not in a `transfer_*` helper.
23pub const MERGE_EXCLUDED_SECURITY_TABLES: &[&str] = &[
24    "oauth_auth_codes",
25    "oauth_refresh_tokens",
26    "oauth_clients",
27    "webauthn_credentials",
28    "webauthn_challenges",
29    "webauthn_setup_tokens",
30    "user_api_keys",
31    "user_device_certs",
32    "bridge_sessions",
33    "bridge_exchange_codes",
34    "federated_identities",
35];
36
37impl UserRepository {
38    pub async fn merge_users(&self, source_id: &UserId, target_id: &UserId) -> Result<MergeResult> {
39        let mut conn = self.write_pool.acquire().await?;
40        let mut tx = conn.begin().await?;
41
42        let source = source_id.as_str();
43        let target = target_id.as_str();
44
45        let sessions = transfer_sessions(&mut tx, source, target).await?;
46        let tasks = transfer_tasks(&mut tx, source, target).await?;
47        let mut total_rows = sessions + tasks;
48        total_rows += transfer_audit_rows(&mut tx, source, target).await?;
49        total_rows += transfer_content_rows(&mut tx, source, target).await?;
50
51        sqlx::query!(
52            "UPDATE fingerprint_reputation SET associated_user_ids = \
53             array_replace(associated_user_ids, $2, $1) WHERE $2 = ANY(associated_user_ids)",
54            target,
55            source
56        )
57        .execute(&mut *tx)
58        .await?;
59
60        sqlx::query!(
61            "DELETE FROM ai_quota_buckets WHERE subject_kind = 'user' AND subject_id = $1",
62            source
63        )
64        .execute(&mut *tx)
65        .await?;
66
67        sqlx::query!("DELETE FROM users WHERE id = $1", source)
68            .execute(&mut *tx)
69            .await?;
70
71        tx.commit().await?;
72        Ok(MergeResult {
73            sessions,
74            tasks,
75            total_rows,
76        })
77    }
78}
79
80async fn transfer_sessions(
81    tx: &mut Transaction<'_, Postgres>,
82    source: &str,
83    target: &str,
84) -> Result<u64> {
85    let result = sqlx::query!(
86        "UPDATE user_sessions SET user_id = $1 WHERE user_id = $2",
87        target,
88        source
89    )
90    .execute(&mut **tx)
91    .await?;
92    Ok(result.rows_affected())
93}
94
95async fn transfer_tasks(
96    tx: &mut Transaction<'_, Postgres>,
97    source: &str,
98    target: &str,
99) -> Result<u64> {
100    let result = sqlx::query!(
101        "UPDATE agent_tasks SET user_id = $1 WHERE user_id = $2",
102        target,
103        source
104    )
105    .execute(&mut **tx)
106    .await?;
107    Ok(result.rows_affected())
108}
109
110async fn transfer_audit_rows(
111    tx: &mut Transaction<'_, Postgres>,
112    source: &str,
113    target: &str,
114) -> Result<u64> {
115    let mut moved = 0;
116    moved += sqlx::query!(
117        "UPDATE task_messages SET user_id = $1 WHERE user_id = $2",
118        target,
119        source
120    )
121    .execute(&mut **tx)
122    .await?
123    .rows_affected();
124    moved += sqlx::query!(
125        "UPDATE user_contexts SET user_id = $1 WHERE user_id = $2",
126        target,
127        source
128    )
129    .execute(&mut **tx)
130    .await?
131    .rows_affected();
132    moved += sqlx::query!(
133        "UPDATE mcp_tool_executions SET user_id = $1 WHERE user_id = $2",
134        target,
135        source
136    )
137    .execute(&mut **tx)
138    .await?
139    .rows_affected();
140    moved += sqlx::query!(
141        "UPDATE mcp_artifacts SET user_id = $1 WHERE user_id = $2",
142        target,
143        source
144    )
145    .execute(&mut **tx)
146    .await?
147    .rows_affected();
148    moved += sqlx::query!(
149        "UPDATE mcp_sessions SET user_id = $1 WHERE user_id = $2",
150        target,
151        source
152    )
153    .execute(&mut **tx)
154    .await?
155    .rows_affected();
156    moved += sqlx::query!(
157        "UPDATE governance_decisions SET user_id = $1 WHERE user_id = $2",
158        target,
159        source
160    )
161    .execute(&mut **tx)
162    .await?
163    .rows_affected();
164    moved += sqlx::query!(
165        "UPDATE logs SET user_id = $1 WHERE user_id = $2",
166        target,
167        source
168    )
169    .execute(&mut **tx)
170    .await?
171    .rows_affected();
172    Ok(moved)
173}
174
175async fn transfer_content_rows(
176    tx: &mut Transaction<'_, Postgres>,
177    source: &str,
178    target: &str,
179) -> Result<u64> {
180    let mut moved = 0;
181    moved += sqlx::query!(
182        "UPDATE ai_requests SET user_id = $1 WHERE user_id = $2",
183        target,
184        source
185    )
186    .execute(&mut **tx)
187    .await?
188    .rows_affected();
189    moved += sqlx::query!(
190        "UPDATE engagement_events SET user_id = $1 WHERE user_id = $2",
191        target,
192        source
193    )
194    .execute(&mut **tx)
195    .await?
196    .rows_affected();
197    moved += sqlx::query!(
198        "UPDATE analytics_events SET user_id = $1 WHERE user_id = $2",
199        target,
200        source
201    )
202    .execute(&mut **tx)
203    .await?
204    .rows_affected();
205    moved += sqlx::query!(
206        "UPDATE event_outbox SET user_id = $1 WHERE user_id = $2",
207        target,
208        source
209    )
210    .execute(&mut **tx)
211    .await?
212    .rows_affected();
213    moved += sqlx::query!(
214        "UPDATE files SET user_id = $1 WHERE user_id = $2",
215        target,
216        source
217    )
218    .execute(&mut **tx)
219    .await?
220    .rows_affected();
221    moved += sqlx::query!(
222        "UPDATE link_clicks SET user_id = $1 WHERE user_id = $2",
223        target,
224        source
225    )
226    .execute(&mut **tx)
227    .await?
228    .rows_affected();
229    Ok(moved)
230}