Skip to main content

max / makenotwork

8.8 KB · 306 lines History Blame Raw
1 //! User account CRUD, profile updates, and lookup queries.
2 //!
3 //! The core reads and writes are here; everything with a life of its own is a
4 //! sibling.
5
6 mod admin_queries;
7 mod lifecycle;
8 mod moderation;
9 mod onboarding;
10 mod preferences;
11 mod stripe;
12
13 pub use admin_queries::*;
14 pub use lifecycle::*;
15 pub use moderation::*;
16 pub use onboarding::*;
17 pub use preferences::*;
18 pub use stripe::*;
19
20 use sqlx::PgPool;
21
22 use crate::db::UserId;
23 use crate::db::models::DbUser;
24 use crate::db::validated_types::{Email, Username};
25 use crate::error::Result;
26
27 /// Insert a new user and return the created row.
28 #[tracing::instrument(skip_all)]
29 pub async fn create_user(
30 pool: &PgPool,
31 username: &Username,
32 email: &Email,
33 password_hash: &str,
34 ) -> Result<DbUser> {
35 let user = sqlx::query_as::<_, DbUser>(
36 r"
37 INSERT INTO users (username, email, password_hash)
38 VALUES ($1, $2, $3)
39 RETURNING *
40 ",
41 )
42 .bind(username)
43 .bind(email)
44 .bind(password_hash)
45 .fetch_one(pool)
46 .await?;
47
48 Ok(user)
49 }
50
51 /// Insert a durable example-marketplace creator (see [`crate::seed`]).
52 ///
53 /// Unlike [`create_sandbox_user`], this leaves `is_sandbox` at its `FALSE`
54 /// default so the account and its projects appear on every public surface
55 /// (discover/browse/search gate only on `is_sandbox = FALSE`). It grants
56 /// `can_create_projects`, marks the email verified, and pins the top
57 /// `creator_tier` so no capability gate blocks the item spread seeded in later
58 /// phases. Only ever called by the `--seed-examples` flow, which is itself
59 /// confined to testnot/localhost by [`crate::seed::run`]'s guards.
60 #[tracing::instrument(skip_all)]
61 pub async fn create_example_creator(
62 pool: &PgPool,
63 username: &Username,
64 email: &Email,
65 password_hash: &str,
66 ) -> Result<DbUser> {
67 let user = sqlx::query_as::<_, DbUser>(
68 r"
69 INSERT INTO users (
70 username, email, password_hash,
71 can_create_projects, email_verified, creator_tier
72 )
73 VALUES ($1, $2, $3, TRUE, TRUE, 'everything')
74 RETURNING *
75 ",
76 )
77 .bind(username)
78 .bind(email)
79 .bind(password_hash)
80 .fetch_one(pool)
81 .await?;
82
83 Ok(user)
84 }
85
86 /// Fetch a user by primary key. Returns `None` if not found.
87 #[tracing::instrument(skip_all)]
88 pub async fn get_user_by_id(pool: &PgPool, id: UserId) -> Result<Option<DbUser>> {
89 let user = sqlx::query_as::<_, DbUser>("SELECT * FROM users WHERE id = $1")
90 .bind(id)
91 .fetch_optional(pool)
92 .await?;
93
94 Ok(user)
95 }
96
97 /// Fetch multiple users by ID in a single query.
98 #[tracing::instrument(skip_all)]
99 pub async fn get_users_by_ids(pool: &PgPool, ids: &[UserId]) -> Result<Vec<DbUser>> {
100 let users = sqlx::query_as::<_, DbUser>("SELECT * FROM users WHERE id = ANY($1)")
101 .bind(ids)
102 .fetch_all(pool)
103 .await?;
104 Ok(users)
105 }
106
107 /// Fetch a user by username. Returns `None` if not found.
108 #[tracing::instrument(skip_all)]
109 pub async fn get_user_by_username(pool: &PgPool, username: &Username) -> Result<Option<DbUser>> {
110 let user = sqlx::query_as::<_, DbUser>("SELECT * FROM users WHERE username = $1")
111 .bind(username)
112 .fetch_optional(pool)
113 .await?;
114
115 Ok(user)
116 }
117
118 /// Fetch a user by email address. Returns `None` if not found.
119 #[tracing::instrument(skip_all)]
120 pub async fn get_user_by_email(pool: &PgPool, email: &Email) -> Result<Option<DbUser>> {
121 let user = sqlx::query_as::<_, DbUser>("SELECT * FROM users WHERE email = $1")
122 .bind(email)
123 .fetch_optional(pool)
124 .await?;
125
126 Ok(user)
127 }
128
129 /// Look up a verified user by email (case-insensitive).
130 /// Returns the user ID if a verified account exists with that email.
131 #[tracing::instrument(skip_all)]
132 pub async fn get_verified_user_id_by_email(pool: &PgPool, email: &Email) -> Result<Option<UserId>> {
133 let id = sqlx::query_scalar(
134 "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND email_verified = true",
135 )
136 .bind(email)
137 .fetch_optional(pool)
138 .await?;
139
140 Ok(id)
141 }
142
143 /// Update a user's display name and/or bio (COALESCE keeps existing values when `None`).
144 #[tracing::instrument(skip_all)]
145 pub async fn update_user_profile(
146 pool: &PgPool,
147 id: UserId,
148 display_name: Option<&str>,
149 bio: Option<&str>,
150 ) -> Result<DbUser> {
151 let user = sqlx::query_as::<_, DbUser>(
152 r"
153 UPDATE users
154 SET display_name = COALESCE($2, display_name),
155 bio = COALESCE($3, bio)
156 WHERE id = $1
157 RETURNING *
158 ",
159 )
160 .bind(id)
161 .bind(display_name)
162 .bind(bio)
163 .fetch_one(pool)
164 .await?;
165
166 Ok(user)
167 }
168
169 /// Store a user's custom profile-page source (the original, pre-sanitization),
170 /// stamp `custom_pages_updated_at`, and bump the cache generation. The source is
171 /// re-sanitized on render; see [`crate::custom_pages`].
172 #[tracing::instrument(skip_all, fields(user_id = %id))]
173 pub async fn update_user_custom_page<'e>(
174 executor: impl sqlx::PgExecutor<'e>,
175 id: UserId,
176 custom_html: &str,
177 custom_css: &str,
178 ) -> Result<DbUser> {
179 let user = sqlx::query_as::<_, DbUser>(
180 r"
181 UPDATE users
182 SET custom_html = $2,
183 custom_css = $3,
184 custom_pages_updated_at = now(),
185 cache_generation = cache_generation + 1
186 WHERE id = $1
187 RETURNING *
188 ",
189 )
190 .bind(id)
191 .bind(custom_html)
192 .bind(custom_css)
193 .fetch_one(executor)
194 .await?;
195 Ok(user)
196 }
197
198 /// Clear a user's custom profile page back to the platform default.
199 pub async fn reset_user_custom_page(pool: &PgPool, id: UserId) -> Result<()> {
200 sqlx::query(
201 "UPDATE users SET custom_html = '', custom_css = '', \
202 custom_pages_updated_at = NULL, cache_generation = cache_generation + 1 WHERE id = $1",
203 )
204 .bind(id)
205 .execute(pool)
206 .await?;
207 Ok(())
208 }
209
210 /// Set or clear a user's creator theme for their public profile. `None` clears
211 /// to the platform default. The id is validated against the embedded registry
212 /// before this call.
213 #[tracing::instrument(skip_all)]
214 pub async fn update_user_theme(pool: &PgPool, id: UserId, theme_id: Option<&str>) -> Result<()> {
215 sqlx::query("UPDATE users SET theme_id = $2, updated_at = NOW() WHERE id = $1")
216 .bind(id)
217 .bind(theme_id)
218 .execute(pool)
219 .await?;
220
221 Ok(())
222 }
223
224 /// Set a user's SSH console theme. Takes a `makeover::ThemeSelection` string
225 /// (a bundled theme id, or `"system"`), validated before this call.
226 #[tracing::instrument(skip_all)]
227 pub async fn update_user_console_theme(pool: &PgPool, id: UserId, selection: &str) -> Result<()> {
228 sqlx::query("UPDATE users SET console_theme = $2, updated_at = NOW() WHERE id = $1")
229 .bind(id)
230 .bind(selection)
231 .execute(pool)
232 .await?;
233
234 Ok(())
235 }
236
237 /// Replace a user's password hash and invalidate outstanding JWTs.
238 #[tracing::instrument(skip_all)]
239 pub async fn update_user_password(pool: &PgPool, id: UserId, password_hash: &str) -> Result<()> {
240 sqlx::query("UPDATE users SET password_hash = $2, jwt_invalidated_at = NOW() WHERE id = $1")
241 .bind(id)
242 .bind(password_hash)
243 .execute(pool)
244 .await?;
245
246 Ok(())
247 }
248
249 /// Increment the user's feed key version, revoking their current personal-feed
250 /// URL. Returns the new version (folded into the next URL's HMAC).
251 #[tracing::instrument(skip_all)]
252 pub async fn bump_feed_key_version(pool: &PgPool, id: UserId) -> Result<i32> {
253 let (version,): (i32,) = sqlx::query_as(
254 "UPDATE users SET feed_key_version = feed_key_version + 1, updated_at = NOW() \
255 WHERE id = $1 RETURNING feed_key_version",
256 )
257 .bind(id)
258 .fetch_one(pool)
259 .await?;
260
261 Ok(version)
262 }
263
264 /// Mark a user's email as verified
265 #[tracing::instrument(skip_all)]
266 pub async fn verify_user_email(pool: &PgPool, user_id: UserId) -> Result<()> {
267 sqlx::query(
268 r"
269 UPDATE users
270 SET email_verified = true,
271 email_verification_token = NULL,
272 updated_at = NOW()
273 WHERE id = $1
274 ",
275 )
276 .bind(user_id)
277 .execute(pool)
278 .await?;
279
280 Ok(())
281 }
282
283 /// Fetch the current cache generation for a user (cheap, indexed lookup).
284 #[tracing::instrument(skip_all)]
285 pub async fn get_cache_generation(pool: &PgPool, user_id: UserId) -> Result<i64> {
286 let generation =
287 sqlx::query_scalar::<_, i64>("SELECT cache_generation FROM users WHERE id = $1")
288 .bind(user_id)
289 .fetch_one(pool)
290 .await?;
291
292 Ok(generation)
293 }
294
295 /// Atomically increment the user's cache generation counter.
296 /// Call after any write that changes user-visible dashboard data.
297 #[tracing::instrument(skip_all)]
298 pub async fn bump_cache_generation(pool: &PgPool, user_id: UserId) -> Result<()> {
299 sqlx::query("UPDATE users SET cache_generation = cache_generation + 1 WHERE id = $1")
300 .bind(user_id)
301 .execute(pool)
302 .await?;
303
304 Ok(())
305 }
306