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)]
46pub struct UserSummary {
47 pub id: String,
48 pub username: Option<String>,
49 #[serde(rename = "displayName")]
50 pub display_name: Option<String>,
51}
52
53pub async fn username_status(state: AppState, username: &str) -> Result<UsernameStatus, AppError> {
55 if is_username_valid(username) {
56 let query = sqlx::query_scalar!("SELECT 1 FROM users WHERE username = $1", username);
57 if query.fetch_optional(&state.db).await?.is_none() {
58 Ok(UsernameStatus::Available)
59 } else {
60 Ok(UsernameStatus::Unavailable)
61 }
62 } else {
63 Ok(UsernameStatus::Invalid)
64 }
65}
66
67#[derive(Clone, Debug, Serialize, TS)]
69pub enum UsernameStatus {
70 Available,
72
73 Unavailable,
75
76 Invalid,
78}
79
80pub async fn get_active_user_profile(ctx: AppCtx) -> Result<UserProfile, AppError> {
82 let Some(user) = ctx.user else {
83 return Err(AppError::Unauthorized);
84 };
85 let query = sqlx::query_as!(
86 UserProfile,
87 "
88 SELECT username, display_name FROM users
89 WHERE id = $1
90 ",
91 user.user_id,
92 );
93 Ok(query.fetch_one(&ctx.state.db).await?)
94}
95
96pub async fn set_active_user_profile(ctx: AppCtx, profile: UserProfile) -> Result<(), AppError> {
98 let Some(user) = ctx.user else {
99 return Err(AppError::Unauthorized);
100 };
101 profile.validate().map_err(AppError::Invalid)?;
102
103 let query = sqlx::query!(
107 "
108 UPDATE users SET username = COALESCE($2, username), display_name = $3
109 WHERE id = $1
110 ",
111 user.user_id,
112 profile.username,
113 profile.display_name,
114 );
115 query.execute(&ctx.state.db).await?;
116 Ok(())
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize, TS)]
121pub struct UserProfile {
122 pub username: Option<String>,
123 #[serde(rename = "displayName")]
124 pub display_name: Option<String>,
125 }
129
130impl UserProfile {
131 fn validate(&self) -> Result<(), String> {
132 if let Some(username) = self.username.as_ref() {
133 is_username_valid(username)
134 .then_some(())
135 .ok_or_else(|| "Username does not follow the rules".into())
136 } else {
137 Ok(())
138 }
139 }
140}
141
142pub fn is_username_valid(username: &str) -> bool {
154 let valid_chars = Regex::new(r"^[0-9A-Za-z_\-\.]*$").unwrap();
155 let starts_alpha = Regex::new(r"^[0-9A-Za-z]").unwrap();
156 let ends_alpha = Regex::new(r"[0-9A-Za-z]$").unwrap();
157
158 valid_chars.is_match(username)
159 && starts_alpha.is_match(username)
160 && ends_alpha.is_match(username)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn validate_user_profile() {
169 assert!(
170 UserProfile {
171 username: None,
172 display_name: None
173 }
174 .validate()
175 .is_ok()
176 );
177
178 assert!(
179 UserProfile {
180 username: Some("evan!".into()),
181 display_name: Some("Evan".into()),
182 }
183 .validate()
184 .is_err()
185 );
186 }
187
188 #[test]
189 fn validate_username() {
190 assert!(!is_username_valid(""));
191 assert!(is_username_valid("foo"));
192 assert!(!is_username_valid("_foo"));
193 assert!(!is_username_valid("foo_"));
194 assert!(is_username_valid("foo_bar"));
195 assert!(is_username_valid("foo-bar"));
196 assert!(is_username_valid("foo.bar"));
197 assert!(!is_username_valid("foo!bar"));
198 }
199}