2094 lines
66 KiB
Rust
2094 lines
66 KiB
Rust
//! 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>,
|
||
}
|
||
|
||
/// 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 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 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 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 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, 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>,
|
||
}
|
||
|
||
/// 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, 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, 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),
|
||
}
|
||
}
|
||
}
|