systemprompt_users/repository/user/
archive.rs1use chrono::{DateTime, Utc};
17use systemprompt_identifiers::UserId;
18
19use crate::error::{Result, UserError};
20use crate::models::UserStatus;
21use crate::repository::UserRepository;
22
23#[derive(Debug, Clone, Copy, Default)]
25pub struct ArchiveParams<'a> {
26 pub archived_by: Option<&'a UserId>,
27 pub reason: Option<&'a str>,
28 pub legal_hold: bool,
29}
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct ArchiveOutcome {
34 pub sessions: u64,
35 pub api_keys: u64,
36 pub device_certs: u64,
37}
38
39#[derive(Debug, Clone)]
42pub struct ArchiveState {
43 pub id: UserId,
44 pub status: String,
45 pub archived_at: Option<DateTime<Utc>>,
46 pub archived_by: Option<String>,
47 pub archive_reason: Option<String>,
48 pub legal_hold: bool,
49}
50
51impl ArchiveState {
52 #[must_use]
53 pub fn is_archived(&self) -> bool {
54 self.status == UserStatus::Deleted.as_str()
55 }
56}
57
58impl UserRepository {
59 pub async fn archive(&self, id: &UserId, params: ArchiveParams<'_>) -> Result<ArchiveOutcome> {
60 let mut tx = self.write_pool.begin().await?;
61 let archived = sqlx::query!(
62 r#"
63 UPDATE users
64 SET status = $2,
65 archived_at = COALESCE(archived_at, NOW()),
66 archived_by = COALESCE(archived_by, $3),
67 archive_reason = COALESCE(archive_reason, $4),
68 legal_hold = legal_hold OR $5,
69 updated_at = NOW()
70 WHERE id = $1
71 "#,
72 id.as_str(),
73 UserStatus::Deleted.as_str(),
74 params.archived_by.map(UserId::as_str),
75 params.reason,
76 params.legal_hold
77 )
78 .execute(&mut *tx)
79 .await?;
80 if archived.rows_affected() == 0 {
81 return Err(UserError::NotFound(id.clone()));
82 }
83 let sessions = sqlx::query!(
84 "UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
85 id.as_str()
86 )
87 .execute(&mut *tx)
88 .await?;
89 let api_keys = sqlx::query!(
90 "UPDATE user_api_keys SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
91 id.as_str()
92 )
93 .execute(&mut *tx)
94 .await?;
95 let device_certs = sqlx::query!(
96 "UPDATE user_device_certs SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
97 id.as_str()
98 )
99 .execute(&mut *tx)
100 .await?;
101 tx.commit().await?;
102 Ok(ArchiveOutcome {
103 sessions: sessions.rows_affected(),
104 api_keys: api_keys.rows_affected(),
105 device_certs: device_certs.rows_affected(),
106 })
107 }
108
109 pub async fn restore(&self, id: &UserId, window_days: u32) -> Result<bool> {
110 let restored = sqlx::query!(
111 r#"
112 UPDATE users
113 SET status = $2, archived_at = NULL, archived_by = NULL,
114 archive_reason = NULL, updated_at = NOW()
115 WHERE id = $1 AND status = $3
116 AND (archived_at IS NULL
117 OR archived_at > NOW() - make_interval(days => $4::int))
118 "#,
119 id.as_str(),
120 UserStatus::Active.as_str(),
121 UserStatus::Deleted.as_str(),
122 i32::try_from(window_days).unwrap_or(i32::MAX)
123 )
124 .execute(&*self.write_pool)
125 .await?;
126 Ok(restored.rows_affected() > 0)
127 }
128
129 pub async fn set_legal_hold(&self, id: &UserId, hold: bool) -> Result<()> {
130 let updated = sqlx::query!(
131 "UPDATE users SET legal_hold = $2, updated_at = NOW() WHERE id = $1",
132 id.as_str(),
133 hold
134 )
135 .execute(&*self.write_pool)
136 .await?;
137 if updated.rows_affected() == 0 {
138 return Err(UserError::NotFound(id.clone()));
139 }
140 Ok(())
141 }
142
143 pub async fn find_archive_state(&self, id: &UserId) -> Result<Option<ArchiveState>> {
144 let row = sqlx::query!(
145 r#"
146 SELECT id, status, archived_at, archived_by, archive_reason, legal_hold
147 FROM users WHERE id = $1
148 "#,
149 id.as_str()
150 )
151 .fetch_optional(&*self.pool)
152 .await?;
153 Ok(row.map(|r| ArchiveState {
154 id: UserId::new(r.id),
155 status: r.status,
156 archived_at: r.archived_at,
157 archived_by: r.archived_by,
158 archive_reason: r.archive_reason,
159 legal_hold: r.legal_hold,
160 }))
161 }
162
163 pub async fn list_purgeable_archives(
164 &self,
165 window_days: u32,
166 limit: i64,
167 ) -> Result<Vec<UserId>> {
168 let ids = sqlx::query_scalar!(
169 r#"
170 SELECT id FROM users
171 WHERE status = $1 AND NOT legal_hold AND archived_at IS NOT NULL
172 AND archived_at < NOW() - make_interval(days => $2::int)
173 ORDER BY archived_at
174 LIMIT $3
175 "#,
176 UserStatus::Deleted.as_str(),
177 i32::try_from(window_days).unwrap_or(i32::MAX),
178 limit
179 )
180 .fetch_all(&*self.pool)
181 .await?;
182 Ok(ids.into_iter().map(UserId::new).collect())
183 }
184}