sl_map_web/routes/
users.rs1use axum::Json;
15use axum::extract::{Path, State};
16use axum::http::StatusCode as ReqwestStatusCode;
17use axum::response::{IntoResponse as _, Response};
18use axum_extra::extract::cookie::SignedCookieJar;
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::auth::{self, CurrentUser, removal_cookie};
24use crate::error::Error;
25use crate::state::AppState;
26
27use crate::library::is_canonical_hex_color;
28
29#[derive(Debug, Clone, Serialize)]
33pub struct UserProfile {
34 pub user_id: Uuid,
36 pub username: String,
38 pub legacy_name: String,
40 pub created_at: DateTime<Utc>,
42}
43
44pub async fn get(
58 _user: CurrentUser,
59 State(state): State<AppState>,
60 Path(user_id): Path<Uuid>,
61) -> Result<Json<UserProfile>, Error> {
62 let row: Option<(String, String, DateTime<Utc>)> =
63 sqlx::query_as("SELECT username, legacy_name, created_at FROM users WHERE user_id = ?1")
64 .bind(user_id.as_bytes().to_vec())
65 .fetch_optional(&state.db)
66 .await
67 .map_err(|err| {
68 tracing::error!("user profile lookup failed: {err}");
69 Error::Database
70 })?;
71 let (username, legacy_name, created_at) =
72 row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))?;
73 Ok(Json(UserProfile {
74 user_id,
75 username,
76 legacy_name,
77 created_at,
78 }))
79}
80
81pub async fn delete_me(
101 user: CurrentUser,
102 State(state): State<AppState>,
103 jar: SignedCookieJar,
104) -> Result<(SignedCookieJar, Response), Error> {
105 let sole_owner_groups: Vec<(Vec<u8>, String)> = sqlx::query_as(
110 "SELECT g.group_id, g.name \
111 FROM \"groups\" AS g \
112 JOIN group_memberships AS gm \
113 ON gm.group_id = g.group_id \
114 WHERE gm.user_id = ?1 \
115 AND gm.role = 'owner' \
116 AND (SELECT COUNT(*) FROM group_memberships \
117 WHERE group_id = g.group_id AND role = 'owner') = 1",
118 )
119 .bind(user.user_id.as_bytes().to_vec())
120 .fetch_all(&state.db)
121 .await
122 .map_err(|err| {
123 tracing::error!("sole-owner precheck failed: {err}");
124 Error::Database
125 })?;
126 if !sole_owner_groups.is_empty() {
127 let names: Vec<String> = sole_owner_groups.into_iter().map(|(_, n)| n).collect();
128 return Err(Error::BadRequest(format!(
129 "you are the sole owner of the following group(s): {}. \
130 Promote another member to owner or delete the group(s) first.",
131 names.join(", ")
132 )));
133 }
134
135 let result = sqlx::query("DELETE FROM users WHERE user_id = ?1")
136 .bind(user.user_id.as_bytes().to_vec())
137 .execute(&state.db)
138 .await
139 .map_err(|err| {
140 tracing::error!("delete user failed: {err}");
141 Error::Database
142 })?;
143 if result.rows_affected() == 0 {
144 tracing::warn!("delete_me: user row was already gone");
147 }
148 if let Some(session_id) = auth::session_id_from_jar(&jar, &state.config.cookie_name) {
152 drop(auth::delete_session(&state.db, &session_id).await);
153 }
154 let cleared = jar.add(removal_cookie(&state.config));
155 Ok((cleared, (ReqwestStatusCode::NO_CONTENT, "").into_response()))
156}
157
158#[derive(Debug, Deserialize)]
161pub struct UpdatePreferences {
162 pub route_color: Option<String>,
164}
165
166pub async fn update_preferences(
176 user: CurrentUser,
177 State(state): State<AppState>,
178 Json(req): Json<UpdatePreferences>,
179) -> Result<Response, Error> {
180 if let Some(ref c) = req.route_color
181 && !is_canonical_hex_color(c)
182 {
183 return Err(Error::BadRequest(format!(
184 "route_color must be canonical `#rrggbb`, got {c:?}"
185 )));
186 }
187 sqlx::query("UPDATE users SET route_color = ?1 WHERE user_id = ?2")
188 .bind(&req.route_color)
189 .bind(user.user_id.as_bytes().to_vec())
190 .execute(&state.db)
191 .await
192 .map_err(|err| {
193 tracing::error!("update preferences failed: {err}");
194 Error::Database
195 })?;
196 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
197}
198
199#[derive(Debug, Serialize)]
202pub struct SavedColors {
203 pub colors: Vec<String>,
205}
206
207pub async fn list_colors(
215 user: CurrentUser,
216 State(state): State<AppState>,
217) -> Result<Json<SavedColors>, Error> {
218 let colors: Vec<String> = sqlx::query_scalar(
219 "SELECT color FROM saved_colors WHERE user_id = ?1 ORDER BY created_at, color",
220 )
221 .bind(user.user_id.as_bytes().to_vec())
222 .fetch_all(&state.db)
223 .await
224 .map_err(|err| {
225 tracing::error!("list saved colors failed: {err}");
226 Error::Database
227 })?;
228 Ok(Json(SavedColors { colors }))
229}
230
231#[derive(Debug, Deserialize)]
233pub struct AddColor {
234 pub color: String,
236}
237
238pub async fn add_color(
248 user: CurrentUser,
249 State(state): State<AppState>,
250 Json(req): Json<AddColor>,
251) -> Result<Response, Error> {
252 if !is_canonical_hex_color(&req.color) {
253 return Err(Error::BadRequest(format!(
254 "color must be canonical `#rrggbb`, got {:?}",
255 req.color
256 )));
257 }
258 sqlx::query(
259 "INSERT OR IGNORE INTO saved_colors (user_id, color, created_at) VALUES (?1, ?2, ?3)",
260 )
261 .bind(user.user_id.as_bytes().to_vec())
262 .bind(&req.color)
263 .bind(Utc::now())
264 .execute(&state.db)
265 .await
266 .map_err(|err| {
267 tracing::error!("add saved color failed: {err}");
268 Error::Database
269 })?;
270 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
271}
272
273pub async fn delete_color(
284 user: CurrentUser,
285 State(state): State<AppState>,
286 Path(color): Path<String>,
287) -> Result<Response, Error> {
288 let canonical = format!("#{color}");
289 if !is_canonical_hex_color(&canonical) {
290 return Err(Error::BadRequest(format!(
291 "color path segment must be six hex digits, got {color:?}"
292 )));
293 }
294 sqlx::query("DELETE FROM saved_colors WHERE user_id = ?1 AND color = ?2")
295 .bind(user.user_id.as_bytes().to_vec())
296 .bind(&canonical)
297 .execute(&state.db)
298 .await
299 .map_err(|err| {
300 tracing::error!("delete saved color failed: {err}");
301 Error::Database
302 })?;
303 Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
304}
305
306#[cfg(test)]
307mod tests {
308 use super::is_canonical_hex_color;
309
310 #[test]
311 fn accepts_canonical_six_digit_hex() {
312 assert!(is_canonical_hex_color("#ff0000"));
313 assert!(is_canonical_hex_color("#FF00aa"));
314 assert!(is_canonical_hex_color("#000000"));
315 }
316
317 #[test]
318 fn rejects_non_canonical() {
319 assert!(!is_canonical_hex_color("ff0000"), "missing leading #");
320 assert!(
321 !is_canonical_hex_color("#fff"),
322 "shorthand is not canonical"
323 );
324 assert!(!is_canonical_hex_color("#ff00000"), "too many digits");
325 assert!(!is_canonical_hex_color("#ff00gg"), "non-hex digit");
326 assert!(!is_canonical_hex_color("#ff 000"), "embedded space");
327 assert!(!is_canonical_hex_color(""), "empty string");
328 assert!(!is_canonical_hex_color("#"), "hash only");
329 }
330}