chattyness/crates/chattyness-db/src/models.rs
2026-01-19 11:48:12 -06:00

2145 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Database models for chattyness.
//!
//! These structs mirror the database schema and are used for SQLx queries.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[cfg(feature = "ssr")]
use chattyness_error::AppError;
#[cfg(feature = "ssr")]
use chattyness_shared::validation;
// =============================================================================
// Enums (matching PostgreSQL ENUMs)
// =============================================================================
/// Realm privacy setting.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "realm_privacy", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum RealmPrivacy {
#[default]
Public,
Unlisted,
Private,
}
impl std::fmt::Display for RealmPrivacy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RealmPrivacy::Public => write!(f, "public"),
RealmPrivacy::Unlisted => write!(f, "unlisted"),
RealmPrivacy::Private => write!(f, "private"),
}
}
}
impl std::str::FromStr for RealmPrivacy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"public" => Ok(RealmPrivacy::Public),
"unlisted" => Ok(RealmPrivacy::Unlisted),
"private" => Ok(RealmPrivacy::Private),
_ => Err(format!("Invalid privacy setting: {}", s)),
}
}
}
impl RealmPrivacy {
/// Get the string representation for database storage.
pub fn as_str(&self) -> &'static str {
match self {
RealmPrivacy::Public => "public",
RealmPrivacy::Unlisted => "unlisted",
RealmPrivacy::Private => "private",
}
}
}
/// Server-wide reputation tier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "reputation_tier", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum ReputationTier {
Guest,
#[default]
Member,
Established,
Trusted,
Elder,
}
/// User account status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "account_status", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum AccountStatus {
#[default]
Active,
Suspended,
Banned,
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))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "auth_provider", rename_all = "snake_case")
)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
#[default]
Local,
OauthGoogle,
OauthDiscord,
OauthGithub,
}
/// Server-level staff role.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "server_role", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum ServerRole {
#[default]
Moderator,
Admin,
Owner,
}
impl std::fmt::Display for ServerRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ServerRole::Moderator => write!(f, "moderator"),
ServerRole::Admin => write!(f, "admin"),
ServerRole::Owner => write!(f, "owner"),
}
}
}
impl std::str::FromStr for ServerRole {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"moderator" => Ok(ServerRole::Moderator),
"admin" => Ok(ServerRole::Admin),
"owner" => Ok(ServerRole::Owner),
_ => Err(format!("Invalid server role: {}", s)),
}
}
}
/// Realm membership role.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "realm_role", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum RealmRole {
#[default]
Member,
Builder,
Moderator,
Owner,
}
impl std::fmt::Display for RealmRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RealmRole::Member => write!(f, "member"),
RealmRole::Builder => write!(f, "builder"),
RealmRole::Moderator => write!(f, "moderator"),
RealmRole::Owner => write!(f, "owner"),
}
}
}
impl std::str::FromStr for RealmRole {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"member" => Ok(RealmRole::Member),
"builder" => Ok(RealmRole::Builder),
"moderator" => Ok(RealmRole::Moderator),
"owner" => Ok(RealmRole::Owner),
_ => Err(format!("Invalid realm role: {}", s)),
}
}
}
/// Scene dimension mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "dimension_mode", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum DimensionMode {
#[default]
Fixed,
Viewport,
}
impl std::fmt::Display for DimensionMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DimensionMode::Fixed => write!(f, "fixed"),
DimensionMode::Viewport => write!(f, "viewport"),
}
}
}
impl std::str::FromStr for DimensionMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"fixed" => Ok(DimensionMode::Fixed),
"viewport" => Ok(DimensionMode::Viewport),
_ => Err(format!("Invalid dimension mode: {}", s)),
}
}
}
/// Interactive spot type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "spot_type", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum SpotType {
#[default]
Normal,
Door,
Trigger,
}
impl std::fmt::Display for SpotType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SpotType::Normal => write!(f, "normal"),
SpotType::Door => write!(f, "door"),
SpotType::Trigger => write!(f, "trigger"),
}
}
}
impl std::str::FromStr for SpotType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"normal" => Ok(SpotType::Normal),
"door" => Ok(SpotType::Door),
"trigger" => Ok(SpotType::Trigger),
_ => Err(format!("Invalid spot type: {}", s)),
}
}
}
/// Avatar layer for prop positioning (z-depth).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "avatar_layer", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum AvatarLayer {
Skin,
#[default]
Clothes,
Accessories,
}
impl std::fmt::Display for AvatarLayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AvatarLayer::Skin => write!(f, "skin"),
AvatarLayer::Clothes => write!(f, "clothes"),
AvatarLayer::Accessories => write!(f, "accessories"),
}
}
}
impl std::str::FromStr for AvatarLayer {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"skin" => Ok(AvatarLayer::Skin),
"clothes" => Ok(AvatarLayer::Clothes),
"accessories" => Ok(AvatarLayer::Accessories),
_ => Err(format!("Invalid avatar layer: {}", s)),
}
}
}
/// Emotion state for avatar emotion overlays.
///
/// Maps to emotion slots 0-11 in the avatar grid:
/// - e0: neutral, e1: happy, e2: sad, e3: angry, e4: surprised
/// - e5: thinking, e6: laughing, e7: crying, e8: love, e9: confused
/// - e10: sleeping, e11: wink
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "emotion_state", rename_all = "lowercase")
)]
#[serde(rename_all = "lowercase")]
pub enum EmotionState {
#[default]
Neutral,
Happy,
Sad,
Angry,
Surprised,
Thinking,
Laughing,
Crying,
Love,
Confused,
Sleeping,
Wink,
}
impl std::fmt::Display for EmotionState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EmotionState::Neutral => write!(f, "neutral"),
EmotionState::Happy => write!(f, "happy"),
EmotionState::Sad => write!(f, "sad"),
EmotionState::Angry => write!(f, "angry"),
EmotionState::Surprised => write!(f, "surprised"),
EmotionState::Thinking => write!(f, "thinking"),
EmotionState::Laughing => write!(f, "laughing"),
EmotionState::Crying => write!(f, "crying"),
EmotionState::Love => write!(f, "love"),
EmotionState::Confused => write!(f, "confused"),
EmotionState::Sleeping => write!(f, "sleeping"),
EmotionState::Wink => write!(f, "wink"),
}
}
}
impl std::str::FromStr for EmotionState {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"neutral" => Ok(EmotionState::Neutral),
"happy" => Ok(EmotionState::Happy),
"sad" => Ok(EmotionState::Sad),
"angry" => Ok(EmotionState::Angry),
"surprised" => Ok(EmotionState::Surprised),
"thinking" => Ok(EmotionState::Thinking),
"laughing" => Ok(EmotionState::Laughing),
"crying" => Ok(EmotionState::Crying),
"love" => Ok(EmotionState::Love),
"confused" => Ok(EmotionState::Confused),
"sleeping" => Ok(EmotionState::Sleeping),
"wink" => Ok(EmotionState::Wink),
_ => Err(format!("Invalid emotion state: {}", s)),
}
}
}
impl EmotionState {
/// Convert a keybinding index (0-11) to an emotion state.
pub fn from_index(i: u8) -> Option<Self> {
match i {
0 => Some(Self::Neutral),
1 => Some(Self::Happy),
2 => Some(Self::Sad),
3 => Some(Self::Angry),
4 => Some(Self::Surprised),
5 => Some(Self::Thinking),
6 => Some(Self::Laughing),
7 => Some(Self::Crying),
8 => Some(Self::Love),
9 => Some(Self::Confused),
10 => Some(Self::Sleeping),
11 => Some(Self::Wink),
_ => None,
}
}
/// Convert emotion state to its index (0-11).
pub fn to_index(&self) -> u8 {
match self {
Self::Neutral => 0,
Self::Happy => 1,
Self::Sad => 2,
Self::Angry => 3,
Self::Surprised => 4,
Self::Thinking => 5,
Self::Laughing => 6,
Self::Crying => 7,
Self::Love => 8,
Self::Confused => 9,
Self::Sleeping => 10,
Self::Wink => 11,
}
}
}
// =============================================================================
// User Models
// =============================================================================
/// A user account.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub display_name: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub reputation_tier: ReputationTier,
pub status: AccountStatus,
pub email_verified: bool,
pub tags: Vec<UserTag>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl User {
/// Check if this user is a guest (has the Guest tag).
pub fn is_guest(&self) -> bool {
self.tags.contains(&UserTag::Guest)
}
}
/// Minimal user info for display purposes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSummary {
pub id: Uuid,
pub username: String,
pub display_name: String,
pub avatar_url: Option<String>,
}
// =============================================================================
// Realm Models
// =============================================================================
/// A realm (themed virtual space).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Realm {
pub id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub tagline: Option<String>,
pub owner_id: Uuid,
pub privacy: RealmPrivacy,
pub is_nsfw: bool,
pub min_reputation_tier: ReputationTier,
pub theme_color: Option<String>,
pub banner_image_path: Option<String>,
pub thumbnail_path: Option<String>,
pub max_users: i32,
pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub default_scene_id: Option<Uuid>,
pub member_count: i32,
pub current_user_count: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Realm with the current user's role (if authenticated and a member).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RealmWithUserRole {
#[serde(flatten)]
pub realm: Realm,
/// The current user's role in this realm, if they are a member.
pub user_role: Option<RealmRole>,
}
/// Request to create a new realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateRealmRequest {
pub name: String,
pub slug: String,
pub description: Option<String>,
pub tagline: Option<String>,
pub privacy: RealmPrivacy,
pub is_nsfw: bool,
pub max_users: i32,
pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub theme_color: Option<String>,
}
#[cfg(feature = "ssr")]
impl CreateRealmRequest {
/// Validate the create realm request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.name, "Realm name")?;
validation::validate_slug(&self.slug)?;
validation::validate_range(self.max_users, "Max users", 1, 10000)?;
validation::validate_optional_hex_color(self.theme_color.as_deref())?;
Ok(())
}
}
/// Minimal realm info for listings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct RealmSummary {
pub id: Uuid,
pub name: String,
pub slug: String,
pub tagline: Option<String>,
pub privacy: RealmPrivacy,
pub is_nsfw: bool,
pub thumbnail_path: Option<String>,
pub member_count: i32,
pub current_user_count: i32,
}
/// Response after creating a realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateRealmResponse {
pub id: Uuid,
pub slug: String,
pub redirect_url: String,
}
// =============================================================================
// Scene Models
// =============================================================================
/// A scene (room within a realm).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Scene {
pub id: Uuid,
pub realm_id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub background_image_path: Option<String>,
pub background_color: Option<String>,
/// Bounds as WKT string (e.g., "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))")
pub bounds_wkt: String,
pub dimension_mode: DimensionMode,
pub ambient_audio_id: Option<Uuid>,
pub ambient_volume: Option<f32>,
pub sort_order: i32,
pub is_entry_point: bool,
pub is_hidden: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// Default public channel ID for this scene.
pub default_channel_id: Option<Uuid>,
}
/// Minimal scene info for listings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct SceneSummary {
pub id: Uuid,
pub name: String,
pub slug: String,
pub sort_order: i32,
pub is_entry_point: bool,
pub is_hidden: bool,
pub background_color: Option<String>,
pub background_image_path: Option<String>,
}
/// A spot (interactive region within a scene).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Spot {
pub id: Uuid,
pub scene_id: Uuid,
pub name: Option<String>,
pub slug: Option<String>,
/// Region as WKT string (e.g., "POLYGON((100 100, 200 100, 200 200, 100 200, 100 100))")
pub region_wkt: String,
pub spot_type: SpotType,
pub destination_scene_id: Option<Uuid>,
/// Destination position as WKT string (e.g., "POINT(400 300)")
pub destination_position_wkt: Option<String>,
pub current_state: i16,
pub sort_order: i32,
pub is_visible: bool,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Minimal spot info for listings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct SpotSummary {
pub id: Uuid,
pub name: Option<String>,
pub slug: Option<String>,
pub spot_type: SpotType,
pub region_wkt: String,
pub sort_order: i32,
pub is_visible: bool,
pub is_active: bool,
}
// =============================================================================
// Props Models
// =============================================================================
/// Origin source for a prop in inventory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
#[cfg_attr(
feature = "ssr",
sqlx(type_name = "prop_origin", rename_all = "snake_case")
)]
#[serde(rename_all = "snake_case")]
pub enum PropOrigin {
#[default]
ServerLibrary,
RealmLibrary,
UserUpload,
}
impl std::fmt::Display for PropOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PropOrigin::ServerLibrary => write!(f, "server_library"),
PropOrigin::RealmLibrary => write!(f, "realm_library"),
PropOrigin::UserUpload => write!(f, "user_upload"),
}
}
}
/// An inventory item (user-owned prop).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct InventoryItem {
pub id: Uuid,
pub prop_name: String,
pub prop_asset_path: String,
pub layer: Option<AvatarLayer>,
pub is_transferable: bool,
pub is_portable: bool,
pub is_droppable: bool,
pub origin: PropOrigin,
pub acquired_at: DateTime<Utc>,
}
/// Response for inventory list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InventoryResponse {
pub items: Vec<InventoryItem>,
}
/// A public prop from server or realm library.
/// Used for the public inventory tabs (Server/Realm).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct PublicProp {
pub id: Uuid,
pub name: String,
pub asset_path: String,
pub description: Option<String>,
}
/// Response for public props list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicPropsResponse {
pub props: Vec<PublicProp>,
}
/// A prop dropped in a channel, available for pickup.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct LooseProp {
pub id: Uuid,
pub channel_id: Uuid,
pub server_prop_id: Option<Uuid>,
pub realm_prop_id: Option<Uuid>,
pub position_x: f64,
pub position_y: f64,
pub dropped_by: Option<Uuid>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
/// Prop name (JOINed from source prop).
pub prop_name: String,
/// Asset path for rendering (JOINed from source prop).
pub prop_asset_path: String,
}
/// A server-wide prop (global library).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ServerProp {
pub id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub tags: Vec<String>,
pub asset_path: String,
pub thumbnail_path: Option<String>,
/// Default content layer (skin/clothes/accessories). Mutually exclusive with default_emotion.
pub default_layer: Option<AvatarLayer>,
/// Default emotion layer (neutral/happy/sad/etc). Mutually exclusive with default_layer.
pub default_emotion: Option<EmotionState>,
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
pub default_position: Option<i16>,
pub is_unique: bool,
pub is_transferable: bool,
pub is_portable: bool,
pub is_droppable: bool,
pub is_public: bool,
pub is_active: bool,
pub available_from: Option<DateTime<Utc>>,
pub available_until: Option<DateTime<Utc>>,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Minimal server prop info for listings.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ServerPropSummary {
pub id: Uuid,
pub name: String,
pub slug: String,
pub asset_path: String,
pub default_layer: Option<AvatarLayer>,
pub is_active: bool,
pub created_at: DateTime<Utc>,
}
/// Request to create a server prop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateServerPropRequest {
pub name: String,
#[serde(default)]
pub slug: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// Default content layer (skin/clothes/accessories). Mutually exclusive with default_emotion.
#[serde(default)]
pub default_layer: Option<AvatarLayer>,
/// Default emotion layer (neutral/happy/sad/etc). Mutually exclusive with default_layer.
#[serde(default)]
pub default_emotion: Option<EmotionState>,
/// Grid position (0-8): top row 0,1,2 / middle 3,4,5 / bottom 6,7,8
#[serde(default)]
pub default_position: Option<i16>,
/// Whether prop is droppable (can be dropped in a channel).
#[serde(default)]
pub droppable: Option<bool>,
/// Whether prop appears in the public Server inventory tab.
#[serde(default)]
pub public: Option<bool>,
}
#[cfg(feature = "ssr")]
impl CreateServerPropRequest {
/// Validate the create prop request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.name, "Prop name")?;
if let Some(ref slug) = self.slug {
validation::validate_slug(slug)?;
}
// Validate grid position is 0-8
if let Some(pos) = self.default_position {
if !(0..=8).contains(&pos) {
return Err(AppError::Validation(
"default_position must be between 0 and 8".to_string(),
));
}
}
// Validate mutual exclusivity: can't have both default_layer and default_emotion
if self.default_layer.is_some() && self.default_emotion.is_some() {
return Err(AppError::Validation(
"Cannot specify both default_layer and default_emotion - they are mutually exclusive".to_string(),
));
}
// If either layer or emotion is set, position must also be set
if (self.default_layer.is_some() || self.default_emotion.is_some())
&& self.default_position.is_none()
{
return Err(AppError::Validation(
"default_position is required when default_layer or default_emotion is set"
.to_string(),
));
}
Ok(())
}
/// Generate a slug from the name if not provided.
pub fn slug_or_generate(&self) -> String {
self.slug.clone().unwrap_or_else(|| {
self.name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string()
})
}
}
/// A saved avatar configuration (up to 10 per user).
///
/// Contains 117 prop slot references:
/// - 27 content layer slots (3 layers × 9 positions)
/// - 90 emotion layer slots (10 emotions × 9 positions)
///
/// Grid positions (0-8):
/// ```text
/// ┌───┬───┬───┐
/// │ 0 │ 1 │ 2 │ top row
/// ├───┼───┼───┤
/// │ 3 │ 4 │ 5 │ middle row
/// ├───┼───┼───┤
/// │ 6 │ 7 │ 8 │ bottom row
/// └───┴───┴───┘
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Avatar {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
/// Slot number (0-9, keyboard: a0-a9)
pub slot_number: i16,
/// Last used emotion slot (0-9, NULL if none)
pub last_emotion: Option<i16>,
// Content Layer: Skin (behind user, body/face)
pub l_skin_0: Option<Uuid>,
pub l_skin_1: Option<Uuid>,
pub l_skin_2: Option<Uuid>,
pub l_skin_3: Option<Uuid>,
pub l_skin_4: Option<Uuid>,
pub l_skin_5: Option<Uuid>,
pub l_skin_6: Option<Uuid>,
pub l_skin_7: Option<Uuid>,
pub l_skin_8: Option<Uuid>,
// Content Layer: Clothes (with user, worn items)
pub l_clothes_0: Option<Uuid>,
pub l_clothes_1: Option<Uuid>,
pub l_clothes_2: Option<Uuid>,
pub l_clothes_3: Option<Uuid>,
pub l_clothes_4: Option<Uuid>,
pub l_clothes_5: Option<Uuid>,
pub l_clothes_6: Option<Uuid>,
pub l_clothes_7: Option<Uuid>,
pub l_clothes_8: Option<Uuid>,
// Content Layer: Accessories (in front of user, held/attached items)
pub l_accessories_0: Option<Uuid>,
pub l_accessories_1: Option<Uuid>,
pub l_accessories_2: Option<Uuid>,
pub l_accessories_3: Option<Uuid>,
pub l_accessories_4: Option<Uuid>,
pub l_accessories_5: Option<Uuid>,
pub l_accessories_6: Option<Uuid>,
pub l_accessories_7: Option<Uuid>,
pub l_accessories_8: Option<Uuid>,
// Emotion: Neutral (e0)
pub e_neutral_0: Option<Uuid>,
pub e_neutral_1: Option<Uuid>,
pub e_neutral_2: Option<Uuid>,
pub e_neutral_3: Option<Uuid>,
pub e_neutral_4: Option<Uuid>,
pub e_neutral_5: Option<Uuid>,
pub e_neutral_6: Option<Uuid>,
pub e_neutral_7: Option<Uuid>,
pub e_neutral_8: Option<Uuid>,
// Emotion: Happy (e1)
pub e_happy_0: Option<Uuid>,
pub e_happy_1: Option<Uuid>,
pub e_happy_2: Option<Uuid>,
pub e_happy_3: Option<Uuid>,
pub e_happy_4: Option<Uuid>,
pub e_happy_5: Option<Uuid>,
pub e_happy_6: Option<Uuid>,
pub e_happy_7: Option<Uuid>,
pub e_happy_8: Option<Uuid>,
// Emotion: Sad (e2)
pub e_sad_0: Option<Uuid>,
pub e_sad_1: Option<Uuid>,
pub e_sad_2: Option<Uuid>,
pub e_sad_3: Option<Uuid>,
pub e_sad_4: Option<Uuid>,
pub e_sad_5: Option<Uuid>,
pub e_sad_6: Option<Uuid>,
pub e_sad_7: Option<Uuid>,
pub e_sad_8: Option<Uuid>,
// Emotion: Angry (e3)
pub e_angry_0: Option<Uuid>,
pub e_angry_1: Option<Uuid>,
pub e_angry_2: Option<Uuid>,
pub e_angry_3: Option<Uuid>,
pub e_angry_4: Option<Uuid>,
pub e_angry_5: Option<Uuid>,
pub e_angry_6: Option<Uuid>,
pub e_angry_7: Option<Uuid>,
pub e_angry_8: Option<Uuid>,
// Emotion: Surprised (e4)
pub e_surprised_0: Option<Uuid>,
pub e_surprised_1: Option<Uuid>,
pub e_surprised_2: Option<Uuid>,
pub e_surprised_3: Option<Uuid>,
pub e_surprised_4: Option<Uuid>,
pub e_surprised_5: Option<Uuid>,
pub e_surprised_6: Option<Uuid>,
pub e_surprised_7: Option<Uuid>,
pub e_surprised_8: Option<Uuid>,
// Emotion: Thinking (e5)
pub e_thinking_0: Option<Uuid>,
pub e_thinking_1: Option<Uuid>,
pub e_thinking_2: Option<Uuid>,
pub e_thinking_3: Option<Uuid>,
pub e_thinking_4: Option<Uuid>,
pub e_thinking_5: Option<Uuid>,
pub e_thinking_6: Option<Uuid>,
pub e_thinking_7: Option<Uuid>,
pub e_thinking_8: Option<Uuid>,
// Emotion: Laughing (e6)
pub e_laughing_0: Option<Uuid>,
pub e_laughing_1: Option<Uuid>,
pub e_laughing_2: Option<Uuid>,
pub e_laughing_3: Option<Uuid>,
pub e_laughing_4: Option<Uuid>,
pub e_laughing_5: Option<Uuid>,
pub e_laughing_6: Option<Uuid>,
pub e_laughing_7: Option<Uuid>,
pub e_laughing_8: Option<Uuid>,
// Emotion: Crying (e7)
pub e_crying_0: Option<Uuid>,
pub e_crying_1: Option<Uuid>,
pub e_crying_2: Option<Uuid>,
pub e_crying_3: Option<Uuid>,
pub e_crying_4: Option<Uuid>,
pub e_crying_5: Option<Uuid>,
pub e_crying_6: Option<Uuid>,
pub e_crying_7: Option<Uuid>,
pub e_crying_8: Option<Uuid>,
// Emotion: Love (e8)
pub e_love_0: Option<Uuid>,
pub e_love_1: Option<Uuid>,
pub e_love_2: Option<Uuid>,
pub e_love_3: Option<Uuid>,
pub e_love_4: Option<Uuid>,
pub e_love_5: Option<Uuid>,
pub e_love_6: Option<Uuid>,
pub e_love_7: Option<Uuid>,
pub e_love_8: Option<Uuid>,
// Emotion: Confused (e9)
pub e_confused_0: Option<Uuid>,
pub e_confused_1: Option<Uuid>,
pub e_confused_2: Option<Uuid>,
pub e_confused_3: Option<Uuid>,
pub e_confused_4: Option<Uuid>,
pub e_confused_5: Option<Uuid>,
pub e_confused_6: Option<Uuid>,
pub e_confused_7: Option<Uuid>,
pub e_confused_8: Option<Uuid>,
// Emotion: Sleeping (e10)
pub e_sleeping_0: Option<Uuid>,
pub e_sleeping_1: Option<Uuid>,
pub e_sleeping_2: Option<Uuid>,
pub e_sleeping_3: Option<Uuid>,
pub e_sleeping_4: Option<Uuid>,
pub e_sleeping_5: Option<Uuid>,
pub e_sleeping_6: Option<Uuid>,
pub e_sleeping_7: Option<Uuid>,
pub e_sleeping_8: Option<Uuid>,
// Emotion: Wink (e11)
pub e_wink_0: Option<Uuid>,
pub e_wink_1: Option<Uuid>,
pub e_wink_2: Option<Uuid>,
pub e_wink_3: Option<Uuid>,
pub e_wink_4: Option<Uuid>,
pub e_wink_5: Option<Uuid>,
pub e_wink_6: Option<Uuid>,
pub e_wink_7: Option<Uuid>,
pub e_wink_8: Option<Uuid>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Currently active avatar for a user in a realm.
/// Users can have different active avatars per realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ActiveAvatar {
pub user_id: Uuid,
pub realm_id: Uuid,
pub avatar_id: Uuid,
/// Current emotion slot (0-9, keyboard: e0-e9)
pub current_emotion: i16,
pub updated_at: DateTime<Utc>,
}
// =============================================================================
// Server Config Models
// =============================================================================
/// Server-wide configuration (singleton).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ServerConfig {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub welcome_message: Option<String>,
pub max_users_per_channel: i32,
pub message_rate_limit: i32,
pub message_rate_window_seconds: i32,
pub allow_guest_access: bool,
pub allow_user_uploads: bool,
pub require_email_verification: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Request to update server configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateServerConfigRequest {
pub name: String,
pub description: Option<String>,
pub welcome_message: Option<String>,
pub max_users_per_channel: i32,
pub message_rate_limit: i32,
pub message_rate_window_seconds: i32,
pub allow_guest_access: bool,
pub allow_user_uploads: bool,
pub require_email_verification: bool,
}
#[cfg(feature = "ssr")]
impl UpdateServerConfigRequest {
/// Validate the update request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.name, "Server name")?;
validation::validate_range(self.max_users_per_channel, "Max users per channel", 1, 1000)?;
validation::validate_range(self.message_rate_limit, "Message rate limit", 1, i32::MAX)?;
validation::validate_range(
self.message_rate_window_seconds,
"Message rate window",
1,
i32::MAX,
)?;
Ok(())
}
}
// =============================================================================
// Staff Models
// =============================================================================
/// A server staff member (joined with user info).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct StaffMember {
pub user_id: Uuid,
pub username: String,
pub display_name: String,
pub email: Option<String>,
pub role: ServerRole,
pub appointed_by: Option<Uuid>,
pub appointed_at: DateTime<Utc>,
}
// =============================================================================
// User Management Models
// =============================================================================
/// User listing item (for tables).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserListItem {
pub id: Uuid,
pub username: String,
pub display_name: String,
pub email: Option<String>,
pub status: AccountStatus,
pub reputation_tier: ReputationTier,
pub staff_role: Option<ServerRole>,
pub created_at: DateTime<Utc>,
pub last_seen_at: Option<DateTime<Utc>>,
}
/// Full user detail (for user detail page).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserDetail {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub display_name: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub reputation_tier: ReputationTier,
pub status: AccountStatus,
pub email_verified: bool,
pub staff_role: Option<ServerRole>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_seen_at: Option<DateTime<Utc>>,
}
/// User's realm membership (for user detail page).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserRealmMembership {
pub realm_id: Uuid,
pub realm_name: String,
pub realm_slug: String,
pub role: RealmRole,
pub nickname: Option<String>,
pub joined_at: DateTime<Utc>,
pub last_visited_at: Option<DateTime<Utc>>,
}
// =============================================================================
// Staff Request Models
// =============================================================================
/// Request to create a staff member.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateStaffRequest {
/// Existing user ID to promote to staff.
pub user_id: Option<Uuid>,
/// Or create a new user.
pub new_user: Option<NewUserData>,
/// Role to assign.
pub role: ServerRole,
}
#[cfg(feature = "ssr")]
impl CreateStaffRequest {
/// Validate the create staff request.
pub fn validate(&self) -> Result<(), AppError> {
// Must have either user_id or new_user, not both
match (&self.user_id, &self.new_user) {
(None, None) => {
return Err(AppError::Validation(
"Must provide either user_id or new_user".to_string(),
));
}
(Some(_), Some(_)) => {
return Err(AppError::Validation(
"Cannot provide both user_id and new_user".to_string(),
));
}
_ => {}
}
// Validate new_user if provided
if let Some(new_user) = &self.new_user {
new_user.validate()?;
}
Ok(())
}
}
/// Data for creating a new user (password is auto-generated).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewUserData {
pub username: String,
pub email: String,
pub display_name: String,
}
#[cfg(feature = "ssr")]
impl NewUserData {
/// Validate new user data.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.username, "Username")?;
validation::validate_length(&self.username, "Username", 3, 32)?;
validation::validate_email(&self.email)?;
validation::validate_non_empty(&self.display_name, "Display name")?;
Ok(())
}
}
/// Request to update a user's account status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateUserStatusRequest {
pub status: AccountStatus,
}
/// Request to add a user to a realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddUserToRealmRequest {
pub realm_id: Uuid,
pub role: RealmRole,
}
/// Request to create a standalone user (from owner interface).
/// Password is auto-generated as a random token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub email: String,
pub display_name: String,
/// Optional: make this user a staff member.
pub staff_role: Option<ServerRole>,
}
#[cfg(feature = "ssr")]
impl CreateUserRequest {
/// Validate the create user request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.username, "Username")?;
validation::validate_length(&self.username, "Username", 3, 32)?;
validation::validate_email(&self.email)?;
validation::validate_non_empty(&self.display_name, "Display name")?;
Ok(())
}
}
/// Request to create a realm from the owner interface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnerCreateRealmRequest {
/// Realm name.
pub name: String,
/// Realm slug (URL-friendly identifier).
pub slug: String,
/// Optional description.
pub description: Option<String>,
/// Optional tagline.
pub tagline: Option<String>,
/// Privacy setting.
pub privacy: RealmPrivacy,
/// Is this realm NSFW?
pub is_nsfw: bool,
/// Maximum concurrent users.
pub max_users: i32,
/// Allow guest access?
pub allow_guest_access: bool,
/// Optional theme color.
pub theme_color: Option<String>,
/// Existing user ID to make owner.
pub owner_id: Option<Uuid>,
/// Or create a new user as owner.
pub new_owner: Option<NewUserData>,
}
#[cfg(feature = "ssr")]
impl OwnerCreateRealmRequest {
/// Validate the create realm request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.name, "Realm name")?;
validation::validate_slug(&self.slug)?;
validation::validate_range(self.max_users, "Max users", 1, 10000)?;
validation::validate_optional_hex_color(self.theme_color.as_deref())?;
// Must have either owner_id or new_owner
match (&self.owner_id, &self.new_owner) {
(None, None) => {
return Err(AppError::Validation(
"Must provide either owner_id or new_owner".to_string(),
));
}
(Some(_), Some(_)) => {
return Err(AppError::Validation(
"Cannot provide both owner_id and new_owner".to_string(),
));
}
(None, Some(new_owner)) => {
new_owner.validate()?;
}
_ => {}
}
Ok(())
}
}
/// Realm listing item for owner interface.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct RealmListItem {
pub id: Uuid,
pub name: String,
pub slug: String,
pub tagline: Option<String>,
pub privacy: RealmPrivacy,
pub is_nsfw: bool,
pub owner_id: Uuid,
pub owner_username: String,
pub member_count: i32,
pub current_user_count: i32,
pub created_at: DateTime<Utc>,
}
/// Full realm detail for owner interface.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct RealmDetail {
pub id: Uuid,
pub name: String,
pub slug: String,
pub description: Option<String>,
pub tagline: Option<String>,
pub owner_id: Uuid,
pub owner_username: String,
pub owner_display_name: String,
pub privacy: RealmPrivacy,
pub is_nsfw: bool,
pub min_reputation_tier: ReputationTier,
pub theme_color: Option<String>,
pub banner_image_path: Option<String>,
pub thumbnail_path: Option<String>,
pub max_users: i32,
pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub member_count: i32,
pub current_user_count: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Request to update a realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateRealmRequest {
pub name: String,
pub description: Option<String>,
pub tagline: Option<String>,
pub privacy: RealmPrivacy,
pub is_nsfw: bool,
pub max_users: i32,
pub allow_guest_access: bool,
pub allow_user_teleport: bool,
pub theme_color: Option<String>,
}
#[cfg(feature = "ssr")]
impl UpdateRealmRequest {
/// Validate the update realm request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.name, "Realm name")?;
validation::validate_range(self.max_users, "Max users", 1, 10000)?;
validation::validate_optional_hex_color(self.theme_color.as_deref())?;
Ok(())
}
}
// =============================================================================
// Authentication Models
// =============================================================================
/// Login type for authentication.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LoginType {
Staff,
Realm,
}
/// User with authentication fields for login verification.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct UserWithAuth {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub display_name: String,
pub avatar_url: Option<String>,
pub status: AccountStatus,
pub force_pw_reset: bool,
pub password_hash: Option<String>,
}
/// Request to login.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
pub login_type: LoginType,
/// Required if login_type is Realm.
pub realm_slug: Option<String>,
}
#[cfg(feature = "ssr")]
impl LoginRequest {
/// Validate the login request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.username, "Username")?;
validation::validate_non_empty(&self.password, "Password")?;
if self.login_type == LoginType::Realm && self.realm_slug.is_none() {
return Err(AppError::Validation(
"Realm slug is required for realm login".to_string(),
));
}
Ok(())
}
}
/// Response after successful login.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginResponse {
pub user: UserSummary,
pub redirect_url: String,
pub requires_pw_reset: bool,
/// For realm login: whether user is already a member.
pub is_member: Option<bool>,
/// Original destination for redirect after password reset.
pub original_destination: Option<String>,
/// Staff role if logging in as staff.
pub staff_role: Option<ServerRole>,
/// Realm info if logging into a realm (for join confirmation).
pub realm: Option<RealmSummary>,
}
/// Request to reset password.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordResetRequest {
pub new_password: String,
pub confirm_password: String,
}
#[cfg(feature = "ssr")]
impl PasswordResetRequest {
/// Validate the password reset request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_password(&self.new_password)?;
validation::validate_passwords_match(&self.new_password, &self.confirm_password)?;
Ok(())
}
}
/// Response after password reset.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordResetResponse {
pub success: bool,
pub redirect_url: String,
}
/// Request to sign up (create a new account and join a realm).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignupRequest {
pub username: String,
pub email: Option<String>,
pub display_name: String,
pub password: String,
pub confirm_password: String,
pub realm_slug: String,
}
#[cfg(feature = "ssr")]
impl SignupRequest {
/// Validate the signup request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_username(&self.username)?;
validation::validate_non_empty(&self.display_name, "Display name")?;
validation::validate_length(self.display_name.trim(), "Display name", 1, 50)?;
// Email: basic format if provided and non-empty
if let Some(ref email) = self.email {
let email_trimmed = email.trim();
if !email_trimmed.is_empty() && !validation::is_valid_email(email_trimmed) {
return Err(AppError::Validation("Invalid email address".to_string()));
}
}
validation::validate_password(&self.password)?;
validation::validate_passwords_match(&self.password, &self.confirm_password)?;
// Realm slug: required
if self.realm_slug.trim().is_empty() {
return Err(AppError::Validation(
"Please select a realm to join".to_string(),
));
}
Ok(())
}
}
/// Response after successful signup.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignupResponse {
pub user: UserSummary,
pub redirect_url: String,
pub membership_id: Uuid,
}
/// Request to login as a guest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuestLoginRequest {
pub realm_slug: String,
}
#[cfg(feature = "ssr")]
impl GuestLoginRequest {
/// Validate the guest login request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.realm_slug, "Realm")?;
Ok(())
}
}
/// Response after successful guest login.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuestLoginResponse {
pub guest_name: String,
pub user_id: Uuid,
pub redirect_url: String,
pub realm: RealmSummary,
}
/// Request to join a realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinRealmRequest {
pub realm_id: Uuid,
}
/// Response after joining a realm.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinRealmResponse {
pub success: bool,
pub membership_id: Uuid,
pub redirect_url: String,
}
/// Current user info (for /api/auth/me endpoint).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CurrentUserResponse {
pub user: Option<AuthenticatedUser>,
}
/// Authenticated user info.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthenticatedUser {
pub id: Uuid,
pub username: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub staff_role: Option<ServerRole>,
}
// =============================================================================
// Membership Models
// =============================================================================
/// A realm membership record.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Membership {
pub id: Uuid,
pub realm_id: Uuid,
pub user_id: Uuid,
pub role: RealmRole,
pub nickname: Option<String>,
pub joined_at: DateTime<Utc>,
pub last_visited_at: Option<DateTime<Utc>>,
}
/// Membership with realm info for user's realm list.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct MembershipWithRealm {
pub membership_id: Uuid,
pub realm_id: Uuid,
pub realm_name: String,
pub realm_slug: String,
pub realm_privacy: RealmPrivacy,
pub role: RealmRole,
pub nickname: Option<String>,
pub last_visited_at: Option<DateTime<Utc>>,
}
// =============================================================================
// Scene Request/Response Models
// =============================================================================
/// Request to create a new scene.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSceneRequest {
pub name: String,
pub slug: String,
pub description: Option<String>,
/// URL to download background image from. Server stores locally and sets background_image_path.
pub background_image_url: Option<String>,
/// If true and background_image_url is provided, extract dimensions from the image.
#[serde(default)]
pub infer_dimensions_from_image: bool,
/// Set directly only if not using background_image_url.
pub background_image_path: Option<String>,
pub background_color: Option<String>,
/// Bounds as WKT string (e.g., "POLYGON((0 0, 800 0, 800 600, 0 600, 0 0))")
pub bounds_wkt: Option<String>,
pub dimension_mode: Option<DimensionMode>,
pub sort_order: Option<i32>,
pub is_entry_point: Option<bool>,
pub is_hidden: Option<bool>,
}
#[cfg(feature = "ssr")]
impl CreateSceneRequest {
/// Validate the create scene request.
pub fn validate(&self) -> Result<(), AppError> {
validation::validate_non_empty(&self.name, "Scene name")?;
validation::validate_slug(&self.slug)?;
validation::validate_optional_hex_color(self.background_color.as_deref())?;
Ok(())
}
}
/// Request to update a scene.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSceneRequest {
pub name: Option<String>,
pub description: Option<String>,
/// URL to download background image from. Server stores locally and sets background_image_path.
pub background_image_url: Option<String>,
/// If true and background_image_url is provided, extract dimensions from the image.
#[serde(default)]
pub infer_dimensions_from_image: bool,
/// Set to true to clear the existing background image.
#[serde(default)]
pub clear_background_image: bool,
/// Set directly only if not using background_image_url.
pub background_image_path: Option<String>,
pub background_color: Option<String>,
pub bounds_wkt: Option<String>,
pub dimension_mode: Option<DimensionMode>,
pub sort_order: Option<i32>,
pub is_entry_point: Option<bool>,
pub is_hidden: Option<bool>,
}
#[cfg(feature = "ssr")]
impl UpdateSceneRequest {
/// Validate the update scene request.
pub fn validate(&self) -> Result<(), AppError> {
// Validate name if provided
if let Some(ref name) = self.name {
validation::validate_non_empty(name, "Scene name")?;
}
validation::validate_optional_hex_color(self.background_color.as_deref())?;
Ok(())
}
}
/// Response after creating a scene.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSceneResponse {
pub id: Uuid,
pub slug: String,
}
/// Response for scene list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SceneListResponse {
pub scenes: Vec<SceneSummary>,
}
// =============================================================================
// Spot Request/Response Models
// =============================================================================
/// Request to create a new spot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSpotRequest {
pub name: Option<String>,
pub slug: Option<String>,
/// Region as WKT string (e.g., "POLYGON((100 100, 200 100, 200 200, 100 200, 100 100))")
pub region_wkt: String,
pub spot_type: Option<SpotType>,
pub destination_scene_id: Option<Uuid>,
/// Destination position as WKT string (e.g., "POINT(400 300)")
pub destination_position_wkt: Option<String>,
pub sort_order: Option<i32>,
pub is_visible: Option<bool>,
pub is_active: Option<bool>,
}
#[cfg(feature = "ssr")]
impl CreateSpotRequest {
/// Validate the create spot request.
pub fn validate(&self) -> Result<(), AppError> {
// Validate slug if provided
if let Some(ref slug) = self.slug {
validation::validate_slug(slug)?;
}
validation::validate_non_empty(&self.region_wkt, "Region WKT")?;
// Validate door type has destination
if self.spot_type == Some(SpotType::Door) && self.destination_scene_id.is_none() {
return Err(AppError::Validation(
"Door spots must have a destination scene".to_string(),
));
}
Ok(())
}
}
/// Request to update a spot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSpotRequest {
pub name: Option<String>,
pub slug: Option<String>,
pub region_wkt: Option<String>,
pub spot_type: Option<SpotType>,
pub destination_scene_id: Option<Uuid>,
pub destination_position_wkt: Option<String>,
pub current_state: Option<i16>,
pub sort_order: Option<i32>,
pub is_visible: Option<bool>,
pub is_active: Option<bool>,
}
#[cfg(feature = "ssr")]
impl UpdateSpotRequest {
/// Validate the update spot request.
pub fn validate(&self) -> Result<(), AppError> {
// Validate slug if provided
if let Some(ref slug) = self.slug {
validation::validate_slug(slug)?;
}
// Validate region_wkt if provided
if let Some(ref wkt) = self.region_wkt {
validation::validate_non_empty(wkt, "Region WKT")?;
}
Ok(())
}
}
/// Response after creating a spot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSpotResponse {
pub id: Uuid,
}
/// Response for spot list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpotListResponse {
pub spots: Vec<SpotSummary>,
}
// =============================================================================
// Channel Member Models
// =============================================================================
/// A user's presence in a channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ChannelMember {
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Option<Uuid>,
pub guest_session_id: Option<Uuid>,
/// X coordinate in scene space
pub position_x: f64,
/// Y coordinate in scene space
pub position_y: f64,
/// Facing direction in degrees (0-359)
pub facing_direction: i16,
pub is_moving: bool,
pub is_afk: bool,
pub joined_at: DateTime<Utc>,
pub last_moved_at: DateTime<Utc>,
}
/// Channel member with user info for display.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ChannelMemberInfo {
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Option<Uuid>,
pub guest_session_id: Option<Uuid>,
/// Display name (user's display_name or guest's guest_name)
pub display_name: String,
/// X coordinate in scene space
pub position_x: f64,
/// Y coordinate in scene space
pub position_y: f64,
/// Facing direction in degrees (0-359)
pub facing_direction: i16,
pub is_moving: bool,
pub is_afk: bool,
/// Current emotion slot (0-9)
pub current_emotion: i16,
pub joined_at: DateTime<Utc>,
/// Whether this user is a guest (has the 'guest' tag)
#[serde(default)]
pub is_guest: bool,
}
/// Request to update position in a channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdatePositionRequest {
pub x: f64,
pub y: f64,
}
/// Request to switch emotion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateEmotionRequest {
/// Emotion slot 0-9 (e0-e9 hotkeys)
pub emotion: u8,
}
// =============================================================================
// Avatar Render Data Models
// =============================================================================
/// Data needed to render an avatar's current appearance.
/// Contains the asset paths for all equipped props.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AvatarRenderData {
pub avatar_id: Uuid,
pub current_emotion: i16,
/// Asset paths for skin layer positions 0-8 (None if slot empty)
pub skin_layer: [Option<String>; 9],
/// Asset paths for clothes layer positions 0-8
pub clothes_layer: [Option<String>; 9],
/// Asset paths for accessories layer positions 0-8
pub accessories_layer: [Option<String>; 9],
/// Asset paths for current emotion overlay positions 0-8
pub emotion_layer: [Option<String>; 9],
}
impl Default for AvatarRenderData {
fn default() -> Self {
Self {
avatar_id: Uuid::nil(),
current_emotion: 0,
skin_layer: Default::default(),
clothes_layer: Default::default(),
accessories_layer: Default::default(),
emotion_layer: Default::default(),
}
}
}
/// Channel member with full avatar render data.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelMemberWithAvatar {
pub member: ChannelMemberInfo,
pub avatar: AvatarRenderData,
}
/// Response for channel members list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMembersResponse {
pub members: Vec<ChannelMemberWithAvatar>,
}
/// Response after joining a channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinChannelResponse {
pub member: ChannelMemberInfo,
pub members: Vec<ChannelMemberWithAvatar>,
}
// =============================================================================
// Emotion Availability
// =============================================================================
/// Emotion availability data for the emote command UI.
///
/// Indicates which of the 12 emotions have assets configured for the user's avatar,
/// and provides preview paths (position 4/center) for rendering in the emotion picker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmotionAvailability {
/// Which emotions have at least one non-null asset slot (positions 0-8).
/// Index corresponds to emotion: 0=neutral, 1=happy, 2=sad, etc.
pub available: [bool; 12],
/// Center position (4) asset path for each emotion, used for preview rendering.
/// None if that emotion has no center asset.
pub preview_paths: [Option<String>; 12],
}
impl Default for EmotionAvailability {
fn default() -> Self {
Self {
available: [false; 12],
preview_paths: Default::default(),
}
}
}
// =============================================================================
// Full Avatar with Paths
// =============================================================================
/// Full avatar data with all inventory UUIDs resolved to asset paths.
///
/// This struct contains all 135 slots (27 content layer + 108 emotion layer)
/// with paths pre-resolved, enabling client-side emotion availability computation
/// and rendering without additional server queries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvatarWithPaths {
pub avatar_id: Uuid,
pub current_emotion: i16,
/// Asset paths for skin layer positions 0-8
pub skin_layer: [Option<String>; 9],
/// Asset paths for clothes layer positions 0-8
pub clothes_layer: [Option<String>; 9],
/// Asset paths for accessories layer positions 0-8
pub accessories_layer: [Option<String>; 9],
/// Asset paths for all 12 emotions, each with 9 positions.
/// Index: emotions[emotion_index][position] where emotion_index is 0-11
/// (neutral, happy, sad, angry, surprised, thinking, laughing, crying, love, confused, sleeping, wink)
pub emotions: [[Option<String>; 9]; 12],
/// Whether each emotion has at least one slot populated (UUID exists, even if path lookup failed).
/// This matches the old get_emotion_availability behavior.
pub emotions_available: [bool; 12],
}
impl Default for AvatarWithPaths {
fn default() -> Self {
Self {
avatar_id: Uuid::nil(),
current_emotion: 0,
skin_layer: Default::default(),
clothes_layer: Default::default(),
accessories_layer: Default::default(),
emotions: Default::default(),
emotions_available: [false; 12],
}
}
}
impl AvatarWithPaths {
/// Compute emotion availability from the avatar data.
/// Uses pre-computed emotions_available which checks if UUIDs exist (not just resolved paths).
pub fn compute_emotion_availability(&self) -> EmotionAvailability {
let mut preview_paths: [Option<String>; 12] = Default::default();
for (i, emotion_layer) in self.emotions.iter().enumerate() {
// Preview is position 4 (center)
preview_paths[i] = emotion_layer[4].clone();
}
EmotionAvailability {
available: self.emotions_available,
preview_paths,
}
}
/// Get the 9-path emotion layer for a specific emotion index (0-11).
pub fn get_emotion_layer(&self, emotion: usize) -> [Option<String>; 9] {
if emotion < 12 {
self.emotions[emotion].clone()
} else {
Default::default()
}
}
/// Convert to AvatarRenderData for the current emotion.
pub fn to_render_data(&self) -> AvatarRenderData {
AvatarRenderData {
avatar_id: self.avatar_id,
current_emotion: self.current_emotion,
skin_layer: self.skin_layer.clone(),
clothes_layer: self.clothes_layer.clone(),
accessories_layer: self.accessories_layer.clone(),
emotion_layer: self.get_emotion_layer(self.current_emotion as usize),
}
}
}
// =============================================================================
// Avatar Slot Update Models
// =============================================================================
/// Request to assign an inventory item to an avatar slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssignSlotRequest {
/// Inventory item ID to assign to the slot.
pub inventory_item_id: Uuid,
/// Layer type: "skin", "clothes", "accessories", or an emotion name.
pub layer: String,
/// Grid position (0-8).
pub position: u8,
}
#[cfg(feature = "ssr")]
impl AssignSlotRequest {
/// Validate the assign slot request.
pub fn validate(&self) -> Result<(), AppError> {
if self.position > 8 {
return Err(AppError::Validation(
"Position must be between 0 and 8".to_string(),
));
}
// Validate layer name
let valid_layers = [
"skin",
"clothes",
"accessories",
"neutral",
"happy",
"sad",
"angry",
"surprised",
"thinking",
"laughing",
"crying",
"love",
"confused",
"sleeping",
"wink",
];
if !valid_layers.contains(&self.layer.to_lowercase().as_str()) {
return Err(AppError::Validation(format!(
"Invalid layer: {}",
self.layer
)));
}
Ok(())
}
/// Get the database column name for this slot.
pub fn column_name(&self) -> String {
let layer = self.layer.to_lowercase();
match layer.as_str() {
"skin" => format!("l_skin_{}", self.position),
"clothes" => format!("l_clothes_{}", self.position),
"accessories" => format!("l_accessories_{}", self.position),
_ => format!("e_{}_{}", layer, self.position),
}
}
}
/// Request to clear an avatar slot.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClearSlotRequest {
/// Layer type: "skin", "clothes", "accessories", or an emotion name.
pub layer: String,
/// Grid position (0-8).
pub position: u8,
}
#[cfg(feature = "ssr")]
impl ClearSlotRequest {
/// Validate the clear slot request.
pub fn validate(&self) -> Result<(), AppError> {
if self.position > 8 {
return Err(AppError::Validation(
"Position must be between 0 and 8".to_string(),
));
}
// Validate layer name
let valid_layers = [
"skin",
"clothes",
"accessories",
"neutral",
"happy",
"sad",
"angry",
"surprised",
"thinking",
"laughing",
"crying",
"love",
"confused",
"sleeping",
"wink",
];
if !valid_layers.contains(&self.layer.to_lowercase().as_str()) {
return Err(AppError::Validation(format!(
"Invalid layer: {}",
self.layer
)));
}
Ok(())
}
/// Get the database column name for this slot.
pub fn column_name(&self) -> String {
let layer = self.layer.to_lowercase();
match layer.as_str() {
"skin" => format!("l_skin_{}", self.position),
"clothes" => format!("l_clothes_{}", self.position),
"accessories" => format!("l_accessories_{}", self.position),
_ => format!("e_{}_{}", layer, self.position),
}
}
}