1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use ts_rs::TS;
4
5use super::app::{AppCtx, AppError, AppState};
6
7pub async fn sign_up_or_sign_in(ctx: AppCtx) -> Result<(), AppError> {
9 let Some(user) = ctx.user else {
10 return Err(AppError::Unauthorized);
11 };
12 let query = sqlx::query!(
13 "
14 INSERT INTO users(id, created, signed_in)
15 VALUES ($1, NOW(), NOW())
16 ON CONFLICT (id) DO UPDATE
17 SET signed_in = EXCLUDED.signed_in
18 ",
19 user.user_id,
20 );
21 query.execute(&ctx.state.db).await?;
22 Ok(())
23}
24
25pub async fn user_by_username(
27 state: AppState,
28 username: &str,
29) -> Result<Option<UserSummary>, AppError> {
30 let query = sqlx::query_as!(
31 UserSummary,
32 "
33 SELECT id, username, display_name FROM users WHERE username = $1
34 ",
35 username
36 );
37 Ok(query.fetch_optional(&state.db).await?)
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize, TS)]
45pub struct UserSummary {
46 pub id: String,
47 pub username: Option<String>,
48 #[serde(rename = "displayName")]
49 pub display_name: Option<String>,
50}
51
52pub async fn username_status(state: AppState, username: &str) -> Result<UsernameStatus, AppError> {
54 if is_username_valid(username) {
55 let query = sqlx::query_scalar!("SELECT 1 FROM users WHERE username = $1", username);
56 if query.fetch_optional(&state.db).await?.is_none() {
57 Ok(UsernameStatus::Available)
58 } else {
59 Ok(UsernameStatus::Unavailable)
60 }
61 } else {
62 Ok(UsernameStatus::Invalid)
63 }
64}
65
66#[derive(Clone, Debug, Serialize, TS)]
68pub enum UsernameStatus {
69 Available,
71
72 Unavailable,
74
75 Invalid,
77}
78
79pub async fn get_active_user_profile(ctx: AppCtx) -> Result<UserProfile, AppError> {
81 let Some(user) = ctx.user else {
82 return Err(AppError::Unauthorized);
83 };
84 let query = sqlx::query_as!(
85 UserProfile,
86 "
87 SELECT username, display_name FROM users
88 WHERE id = $1
89 ",
90 user.user_id,
91 );
92 Ok(query.fetch_one(&ctx.state.db).await?)
93}
94
95pub async fn set_active_user_profile(ctx: AppCtx, profile: UserProfile) -> Result<(), AppError> {
97 let Some(user) = ctx.user else {
98 return Err(AppError::Unauthorized);
99 };
100 profile.validate().map_err(AppError::Invalid)?;
101
102 let query = sqlx::query!(
106 "
107 UPDATE users SET username = COALESCE($2, username), display_name = $3
108 WHERE id = $1
109 ",
110 user.user_id,
111 profile.username,
112 profile.display_name,
113 );
114 query.execute(&ctx.state.db).await?;
115 Ok(())
116}
117
118#[derive(Clone, Debug, Serialize, Deserialize, TS)]
120pub struct UserProfile {
121 pub username: Option<String>,
122 #[serde(rename = "displayName")]
123 pub display_name: Option<String>,
124 }
128
129impl UserProfile {
130 fn validate(&self) -> Result<(), String> {
131 if let Some(username) = self.username.as_ref() {
132 is_username_valid(username)
133 .then_some(())
134 .ok_or_else(|| "Username does not follow the rules".into())
135 } else {
136 Ok(())
137 }
138 }
139}
140
141pub fn is_username_valid(username: &str) -> bool {
152 let valid_chars = Regex::new(r"^[0-9A-Za-z_\-\.]*$").unwrap();
153 let starts_alpha = Regex::new(r"^[0-9A-Za-z]").unwrap();
154 let ends_alpha = Regex::new(r"[0-9A-Za-z]$").unwrap();
155
156 valid_chars.is_match(username)
157 && starts_alpha.is_match(username)
158 && ends_alpha.is_match(username)
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn validate_user_profile() {
167 assert!(
168 UserProfile {
169 username: None,
170 display_name: None
171 }
172 .validate()
173 .is_ok()
174 );
175
176 assert!(
177 UserProfile {
178 username: Some("evan!".into()),
179 display_name: Some("Evan".into()),
180 }
181 .validate()
182 .is_err()
183 );
184 }
185
186 #[test]
187 fn validate_username() {
188 assert!(!is_username_valid(""));
189 assert!(is_username_valid("foo"));
190 assert!(!is_username_valid("_foo"));
191 assert!(!is_username_valid("foo_"));
192 assert!(is_username_valid("foo_bar"));
193 assert!(is_username_valid("foo-bar"));
194 assert!(is_username_valid("foo.bar"));
195 assert!(!is_username_valid("foo!bar"));
196 }
197}