Rework avatars.

Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
This commit is contained in:
Evan Carroll 2026-01-22 21:04:27 -06:00
parent e4abdb183f
commit 6fb90e42c3
55 changed files with 7392 additions and 512 deletions

View file

@ -6,14 +6,16 @@ use tower_sessions::Session;
use chattyness_db::{
models::{
AccountStatus, AuthenticatedUser, CurrentUserResponse, GuestLoginRequest,
GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest, LoginResponse,
LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole, RealmSummary,
RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse, UserSummary,
AccountStatus, AgeCategory, AuthenticatedUser, CurrentUserResponse, GenderPreference,
GuestLoginRequest, GuestLoginResponse, JoinRealmRequest, JoinRealmResponse, LoginRequest,
LoginResponse, LoginType, PasswordResetRequest, PasswordResetResponse, RealmRole,
RealmSummary, RegisterGuestRequest, RegisterGuestResponse, SignupRequest, SignupResponse,
UserSummary,
},
queries::{guests, memberships, realms, users},
};
use chattyness_error::AppError;
use chattyness_shared::{AgeConfig, GenderConfig, SignupConfig};
use crate::auth::{
AuthUser, OptionalAuthUser,
@ -249,6 +251,7 @@ pub async fn logout(session: Session) -> Result<Json<LogoutResponse>, AppError>
pub async fn signup(
rls_conn: crate::auth::RlsConn,
State(pool): State<PgPool>,
State(signup_config): State<SignupConfig>,
session: Session,
Json(req): Json<SignupRequest>,
) -> Result<Json<SignupResponse>, AppError> {
@ -273,6 +276,29 @@ pub async fn signup(
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", req.realm_slug)))?;
// Determine gender preference based on config
let gender_preference = match signup_config.gender {
GenderConfig::Ask => req.gender_preference,
GenderConfig::DefaultNeutral => Some(GenderPreference::GenderNeutral),
GenderConfig::DefaultMale => Some(GenderPreference::GenderMale),
GenderConfig::DefaultFemale => Some(GenderPreference::GenderFemale),
};
// Determine age category based on config
let age_category = match signup_config.age {
AgeConfig::Ask => req.age_category,
AgeConfig::Infer => {
// Infer age from birthday if provided
if let Some(birthday) = req.birthday {
Some(infer_age_category_from_birthday(birthday))
} else {
Some(AgeCategory::Adult) // Default to adult if no birthday
}
}
AgeConfig::DefaultAdult => Some(AgeCategory::Adult),
AgeConfig::DefaultChild => Some(AgeCategory::Child),
};
// Create the user using RLS connection
let email_opt = req.email.as_ref().and_then(|e| {
let trimmed = e.trim();
@ -284,12 +310,15 @@ pub async fn signup(
});
let mut conn = rls_conn.acquire().await;
let user_id = users::create_user_conn(
let user_id = users::create_user_with_preferences_conn(
&mut *conn,
&req.username,
email_opt,
req.display_name.trim(),
&req.password,
req.birthday,
gender_preference,
age_category,
)
.await?;
drop(conn);
@ -530,3 +559,65 @@ pub async fn register_guest(
username: req.username,
}))
}
/// Request to update user preferences.
#[derive(Debug, serde::Deserialize)]
pub struct UpdatePreferencesRequest {
#[serde(default)]
pub birthday: Option<chrono::NaiveDate>,
#[serde(default)]
pub gender_preference: Option<chattyness_db::models::GenderPreference>,
#[serde(default)]
pub age_category: Option<chattyness_db::models::AgeCategory>,
}
/// Response after updating preferences.
#[derive(Debug, serde::Serialize)]
pub struct UpdatePreferencesResponse {
pub success: bool,
}
/// Update user preferences handler.
///
/// Updates the user's birthday, gender preference, and/or age category.
/// These preferences are used for default avatar selection.
pub async fn update_preferences(
rls_conn: crate::auth::RlsConn,
AuthUser(user): AuthUser,
Json(req): Json<UpdatePreferencesRequest>,
) -> Result<Json<UpdatePreferencesResponse>, AppError> {
let mut conn = rls_conn.acquire().await;
// Update user preferences (requires RLS for auth.users UPDATE policy)
users::update_user_preferences_conn(
&mut *conn,
user.id,
req.birthday,
req.gender_preference,
req.age_category,
)
.await?;
Ok(Json(UpdatePreferencesResponse { success: true }))
}
/// Infer age category from birthday.
///
/// Returns `Adult` if 18 years or older, `Child` otherwise.
fn infer_age_category_from_birthday(birthday: chrono::NaiveDate) -> AgeCategory {
use chrono::Datelike;
let today = chrono::Utc::now().date_naive();
let age = today.year() - birthday.year();
let had_birthday_this_year =
(today.month(), today.day()) >= (birthday.month(), birthday.day());
let actual_age = if had_birthday_this_year {
age
} else {
age - 1
};
if actual_age >= 18 {
AgeCategory::Adult
} else {
AgeCategory::Child
}
}