Skip to main content

max / makenotwork

7.0 KB · 237 lines History Blame Raw
1 //! An account's arc after it exists: deactivate, terminate, delete, and the
2 //! sandbox accounts that expire on their own.
3
4 use sqlx::PgPool;
5
6 use crate::db::UserId;
7 use crate::db::models::DbUser;
8 use crate::db::validated_types::{Email, Username};
9 use crate::error::Result;
10
11 /// Self-deactivate an account (enter limbo state).
12 ///
13 /// Bumps `jwt_invalidated_at` so any outstanding SyncKit JWTs minted from
14 /// this account stop authenticating immediately.
15 #[tracing::instrument(skip_all)]
16 pub async fn deactivate_user(pool: &PgPool, id: UserId) -> Result<()> {
17 sqlx::query(
18 "UPDATE users SET deactivated_at = NOW(), jwt_invalidated_at = NOW(), updated_at = NOW() WHERE id = $1",
19 )
20 .bind(id)
21 .execute(pool)
22 .await?;
23
24 Ok(())
25 }
26
27 /// Reactivate a self-deactivated account.
28 #[tracing::instrument(skip_all)]
29 pub async fn reactivate_user(pool: &PgPool, id: UserId) -> Result<()> {
30 sqlx::query("UPDATE users SET deactivated_at = NULL, updated_at = NOW() WHERE id = $1")
31 .bind(id)
32 .execute(pool)
33 .await?;
34
35 Ok(())
36 }
37
38 /// Admin: permanently terminate an account (enforcement ladder step 4).
39 /// The user has 30 days to export data. After that, the scheduler deletes the account.
40 /// The account must already be suspended.
41 #[tracing::instrument(skip_all)]
42 pub async fn terminate_user(pool: &PgPool, id: UserId) -> Result<()> {
43 sqlx::query(
44 "UPDATE users SET terminated_at = NOW(), jwt_invalidated_at = NOW(), updated_at = NOW() WHERE id = $1",
45 )
46 .bind(id)
47 .execute(pool)
48 .await?;
49
50 Ok(())
51 }
52
53 /// Get user IDs of terminated accounts whose 30-day export window has expired.
54 #[tracing::instrument(skip_all)]
55 pub async fn get_expired_terminated_ids(pool: &PgPool) -> Result<Vec<UserId>> {
56 let ids: Vec<UserId> = sqlx::query_scalar(
57 r"
58 SELECT id FROM users
59 WHERE terminated_at IS NOT NULL
60 AND terminated_at < NOW() - INTERVAL '30 days'
61 ORDER BY terminated_at
62 LIMIT 1000
63 ",
64 )
65 .fetch_all(pool)
66 .await?;
67
68 Ok(ids)
69 }
70
71 /// Permanently delete a user by ID.
72 ///
73 /// `pub(crate)` and not for direct handler use: go through
74 /// [`crate::AppState::delete_user_account`], which also purges the in-memory
75 /// caches keyed to the user (domain_cache). Deleting here alone would leave a
76 /// stale, never-revalidated cache entry.
77 #[tracing::instrument(skip_all)]
78 pub(crate) async fn delete_user(pool: &PgPool, id: UserId) -> Result<()> {
79 sqlx::query("DELETE FROM users WHERE id = $1")
80 .bind(id)
81 .execute(pool)
82 .await?;
83
84 Ok(())
85 }
86
87 /// Check whether this creator has any completed sales (transactions where they were the seller).
88 #[tracing::instrument(skip_all)]
89 pub async fn has_completed_sales(pool: &PgPool, id: UserId) -> Result<bool> {
90 let count: i64 = sqlx::query_scalar(
91 "SELECT COUNT(*) FROM transactions WHERE seller_id = $1 AND status = 'completed'",
92 )
93 .bind(id)
94 .fetch_one(pool)
95 .await?;
96
97 Ok(count > 0)
98 }
99
100 /// Schedule content removal 90 days from now. The user row is hidden from public
101 /// views but items remain accessible to buyers who previously purchased them.
102 /// After 90 days the scheduler deletes S3 objects and the user row.
103 #[tracing::instrument(skip_all)]
104 pub async fn schedule_content_removal(pool: &PgPool, id: UserId) -> Result<()> {
105 sqlx::query(
106 r"
107 UPDATE users
108 SET content_removal_at = NOW() + INTERVAL '90 days',
109 deactivated_at = NOW(),
110 updated_at = NOW()
111 WHERE id = $1
112 ",
113 )
114 .bind(id)
115 .execute(pool)
116 .await?;
117
118 Ok(())
119 }
120
121 /// Get user IDs whose 90-day content removal grace period has expired.
122 #[tracing::instrument(skip_all)]
123 pub async fn get_expired_content_removal_ids(pool: &PgPool) -> Result<Vec<UserId>> {
124 let ids: Vec<UserId> = sqlx::query_scalar(
125 r"
126 SELECT id FROM users
127 WHERE content_removal_at IS NOT NULL
128 AND content_removal_at < NOW()
129 ORDER BY content_removal_at
130 LIMIT 1000
131 ",
132 )
133 .fetch_all(pool)
134 .await?;
135
136 Ok(ids)
137 }
138
139 /// Create an ephemeral sandbox user. Returns the created row.
140 ///
141 /// The user gets `can_create_projects = true`, `email_verified = true`,
142 /// a SmallFiles creator tier, and a tight storage cap. The row is
143 /// automatically cleaned up by the scheduler after `sandbox_expires_at`.
144 #[tracing::instrument(skip_all)]
145 pub async fn create_sandbox_user(
146 pool: &PgPool,
147 username: &Username,
148 email: &Email,
149 password_hash: &str,
150 expiry_secs: i64,
151 ) -> Result<DbUser> {
152 let user = sqlx::query_as::<_, DbUser>(
153 r"
154 INSERT INTO users (
155 username, email, password_hash,
156 is_sandbox, sandbox_expires_at,
157 can_create_projects, email_verified,
158 creator_tier
159 )
160 VALUES (
161 $1, $2, $3,
162 TRUE, NOW() + make_interval(secs => $4::float8),
163 TRUE, TRUE,
164 'small_files'
165 )
166 RETURNING *
167 ",
168 )
169 .bind(username)
170 .bind(email)
171 .bind(password_hash)
172 .bind(expiry_secs as f64)
173 .fetch_one(pool)
174 .await?;
175
176 Ok(user)
177 }
178
179 /// Return IDs of sandbox users whose expiry has passed.
180 #[tracing::instrument(skip_all)]
181 pub async fn get_expired_sandbox_ids(pool: &PgPool) -> Result<Vec<UserId>> {
182 // Per-tick LIMIT bounds the supervisor's input list (the scheduler re-ticks
183 // and the WHERE re-excludes already-deleted rows, so the remainder is picked
184 // up next tick). Run #12 INFO, keeps a pathological mass-expiry from loading
185 // an unbounded id vec even though concurrency is already capped at 4.
186 let ids = sqlx::query_scalar::<_, UserId>(
187 "SELECT id FROM users WHERE is_sandbox = TRUE AND sandbox_expires_at < NOW() \
188 ORDER BY sandbox_expires_at LIMIT 1000",
189 )
190 .fetch_all(pool)
191 .await?;
192
193 Ok(ids)
194 }
195
196 /// Count active (non-expired) sandbox accounts created from a given IP.
197 /// Used to enforce the per-IP concurrent sandbox cap.
198 #[tracing::instrument(skip_all)]
199 pub async fn count_active_sandboxes_by_ip(pool: &PgPool, ip: &str) -> Result<i64> {
200 let count: i64 = sqlx::query_scalar(
201 r"
202 SELECT COUNT(*) FROM users u
203 JOIN user_sessions us ON us.user_id = u.id
204 WHERE u.is_sandbox = TRUE
205 AND u.sandbox_expires_at > NOW()
206 AND us.ip_address = $1
207 ",
208 )
209 .bind(ip)
210 .fetch_one(pool)
211 .await?;
212
213 Ok(count)
214 }
215
216 /// Set the creator_paused_at timestamp (voluntary pause).
217 #[tracing::instrument(skip_all)]
218 pub async fn pause_creator(pool: &PgPool, user_id: UserId) -> Result<()> {
219 sqlx::query("UPDATE users SET creator_paused_at = NOW(), updated_at = NOW() WHERE id = $1")
220 .bind(user_id)
221 .execute(pool)
222 .await?;
223
224 Ok(())
225 }
226
227 /// Clear the creator_paused_at timestamp (resume from voluntary pause).
228 #[tracing::instrument(skip_all)]
229 pub async fn unpause_creator(pool: &PgPool, user_id: UserId) -> Result<()> {
230 sqlx::query("UPDATE users SET creator_paused_at = NULL, updated_at = NOW() WHERE id = $1")
231 .bind(user_id)
232 .execute(pool)
233 .await?;
234
235 Ok(())
236 }
237