diff --git a/crates/chattyness-admin-ui/src/api/props.rs b/crates/chattyness-admin-ui/src/api/props.rs index a575509..b018666 100644 --- a/crates/chattyness-admin-ui/src/api/props.rs +++ b/crates/chattyness-admin-ui/src/api/props.rs @@ -1,8 +1,9 @@ //! Props management API handlers for admin UI. -use axum::extract::State; +use axum::extract::{Query, State}; use axum::Json; use axum_extra::extract::Multipart; +use serde::Deserialize; use chattyness_db::{ models::{CreateServerPropRequest, ServerProp, ServerPropSummary}, queries::props, @@ -18,6 +19,14 @@ use uuid::Uuid; // API Types // ============================================================================= +/// Query parameters for prop creation. +#[derive(Debug, Deserialize)] +pub struct CreatePropQuery { + /// If true, update existing prop with same slug instead of returning 409 Conflict. + #[serde(default)] + pub force: bool, +} + /// Response for prop creation. #[derive(Debug, Serialize)] pub struct CreatePropResponse { @@ -102,8 +111,12 @@ pub async fn list_props(State(pool): State) -> Result, + Query(query): Query, mut multipart: Multipart, ) -> Result, AppError> { let mut metadata: Option = None; @@ -165,23 +178,27 @@ pub async fn create_prop( // Validate the request metadata.validate()?; - // Check slug availability - let slug = metadata.slug_or_generate(); - let available = props::is_prop_slug_available(&pool, &slug).await?; - if !available { - return Err(AppError::Conflict(format!( - "Prop slug '{}' is already taken", - slug - ))); - } - - // Store the file + // Store the file first (SHA256-based, safe to run even if prop exists) let asset_path = store_prop_file(&file_bytes, &extension).await?; - // Create the prop in database - let prop = props::create_server_prop(&pool, &metadata, &asset_path, None).await?; + let prop = if query.force { + // Force mode: upsert (insert or update) + props::upsert_server_prop(&pool, &metadata, &asset_path, None).await? + } else { + // Normal mode: check availability first + let slug = metadata.slug_or_generate(); + let available = props::is_prop_slug_available(&pool, &slug).await?; + if !available { + return Err(AppError::Conflict(format!( + "Prop slug '{}' is already taken", + slug + ))); + } + props::create_server_prop(&pool, &metadata, &asset_path, None).await? + }; - tracing::info!("Created server prop: {} ({})", prop.name, prop.id); + let action = if query.force { "Upserted" } else { "Created" }; + tracing::info!("{} server prop: {} ({})", action, prop.name, prop.id); Ok(Json(CreatePropResponse::from(prop))) } diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index fa82ce9..5297326 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -88,6 +88,20 @@ pub enum AccountStatus { Deleted, } +/// User account tag for feature gating and access control. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ssr", derive(sqlx::Type))] +#[cfg_attr(feature = "ssr", sqlx(type_name = "user_tag", rename_all = "snake_case"))] +#[serde(rename_all = "snake_case")] +pub enum UserTag { + Guest, + Unvalidated, + ValidatedEmail, + ValidatedSocial, + ValidatedOauth2, + Premium, +} + /// Authentication provider. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "ssr", derive(sqlx::Type))] @@ -399,6 +413,7 @@ pub struct User { pub reputation_tier: ReputationTier, pub status: AccountStatus, pub email_verified: bool, + pub tags: Vec, pub created_at: DateTime, pub updated_at: DateTime, } @@ -1467,7 +1482,7 @@ impl GuestLoginRequest { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GuestLoginResponse { pub guest_name: String, - pub guest_id: Uuid, + pub user_id: Uuid, pub redirect_url: String, pub realm: RealmSummary, } diff --git a/crates/chattyness-db/src/queries/props.rs b/crates/chattyness-db/src/queries/props.rs index 3382382..67ef22f 100644 --- a/crates/chattyness-db/src/queries/props.rs +++ b/crates/chattyness-db/src/queries/props.rs @@ -164,6 +164,99 @@ pub async fn create_server_prop<'e>( Ok(prop) } +/// Upsert a server prop (insert or update on slug conflict). +/// +/// If a prop with the same slug already exists, it will be updated. +/// Otherwise, a new prop will be created. +pub async fn upsert_server_prop<'e>( + executor: impl PgExecutor<'e>, + req: &CreateServerPropRequest, + asset_path: &str, + created_by: Option, +) -> Result { + let slug = req.slug_or_generate(); + + // Positioning: either content layer OR emotion layer OR neither (all NULL) + // Database constraint enforces mutual exclusivity + let (default_layer, default_emotion, default_position) = + if req.default_layer.is_some() { + // Content layer prop + ( + req.default_layer.map(|l| l.to_string()), + None, + Some(req.default_position.unwrap_or(4)), // Default to center position + ) + } else if req.default_emotion.is_some() { + // Emotion layer prop + ( + None, + req.default_emotion.map(|e| e.to_string()), + Some(req.default_position.unwrap_or(4)), // Default to center position + ) + } else { + // Non-avatar prop + (None, None, None) + }; + + let prop = sqlx::query_as::<_, ServerProp>( + r#" + INSERT INTO server.props ( + name, slug, description, tags, asset_path, + default_layer, default_emotion, default_position, + created_by + ) + VALUES ( + $1, $2, $3, $4, $5, + $6::props.avatar_layer, $7::props.emotion_state, $8, + $9 + ) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + tags = EXCLUDED.tags, + asset_path = EXCLUDED.asset_path, + default_layer = EXCLUDED.default_layer, + default_emotion = EXCLUDED.default_emotion, + default_position = EXCLUDED.default_position, + updated_at = now() + RETURNING + id, + name, + slug, + description, + tags, + asset_path, + thumbnail_path, + default_layer, + default_emotion, + default_position, + is_unique, + is_transferable, + is_portable, + is_droppable, + is_active, + available_from, + available_until, + created_by, + created_at, + updated_at + "#, + ) + .bind(&req.name) + .bind(&slug) + .bind(&req.description) + .bind(&req.tags) + .bind(asset_path) + .bind(&default_layer) + .bind(&default_emotion) + .bind(default_position) + .bind(created_by) + .fetch_one(executor) + .await?; + + Ok(prop) +} + /// Delete a server prop. pub async fn delete_server_prop<'e>( executor: impl PgExecutor<'e>, diff --git a/crates/chattyness-db/src/queries/users.rs b/crates/chattyness-db/src/queries/users.rs index 37b8621..29f57e1 100644 --- a/crates/chattyness-db/src/queries/users.rs +++ b/crates/chattyness-db/src/queries/users.rs @@ -20,6 +20,7 @@ pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result, App reputation_tier, status, email_verified, + tags, created_at, updated_at FROM auth.users @@ -47,6 +48,7 @@ pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result Result Result Result { + // Generate unique username from guest_name (e.g., "guest12345") + let username = guest_name.to_lowercase().replace('_', ""); + + let (user_id,): (Uuid,) = sqlx::query_as( + r#" + INSERT INTO auth.users (username, display_name, tags) + VALUES ($1, $2, ARRAY['guest']::auth.user_tag[]) + RETURNING id + "#, + ) + .bind(&username) + .bind(guest_name) + .fetch_one(pool) + .await?; + + Ok(user_id) +} diff --git a/crates/chattyness-user-ui/src/api/auth.rs b/crates/chattyness-user-ui/src/api/auth.rs index 9af8895..a50bb7f 100644 --- a/crates/chattyness-user-ui/src/api/auth.rs +++ b/crates/chattyness-user-ui/src/api/auth.rs @@ -20,8 +20,8 @@ use chattyness_error::AppError; use crate::auth::{ session::{ - hash_token, generate_token, SESSION_CURRENT_REALM_KEY, SESSION_GUEST_ID_KEY, - SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, SESSION_USER_ID_KEY, + SESSION_CURRENT_REALM_KEY, SESSION_LOGIN_TYPE_KEY, SESSION_ORIGINAL_DEST_KEY, + SESSION_USER_ID_KEY, }, AuthUser, OptionalAuthUser, }; @@ -328,6 +328,9 @@ pub async fn signup( } /// Guest login handler. +/// +/// Creates a real user account with the 'guest' tag. Guests are regular users +/// with limited capabilities (no prop pickup, etc.) that can be reaped after 24 hours. pub async fn guest_login( State(pool): State, session: Session, @@ -348,27 +351,15 @@ pub async fn guest_login( )); } - // Generate guest name and session token + // Generate guest name let guest_name = guests::generate_guest_name(); - let token = generate_token(); - let token_hash = hash_token(&token); - let expires_at = guests::guest_session_expiry(); - // Create guest session in database - let guest_id = guests::create_guest_session( - &pool, - &guest_name, - realm.id, - &token_hash, - None, // user_agent - None, // ip_address - expires_at, - ) - .await?; + // Create guest user (no password) - trigger creates avatar automatically + let user_id = users::create_guest_user(&pool, &guest_name).await?; - // Set up tower session + // Set up tower session (same as regular user login) session - .insert(SESSION_GUEST_ID_KEY, guest_id) + .insert(SESSION_USER_ID_KEY, user_id) .await .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; session @@ -384,7 +375,7 @@ pub async fn guest_login( Ok(Json(GuestLoginResponse { guest_name, - guest_id, + user_id, redirect_url, realm: RealmSummary { id: realm.id,