Initial copy of business card prop with profile
This commit is contained in:
parent
4f0f88504a
commit
9541fb1927
16 changed files with 1193 additions and 71 deletions
|
|
@ -866,6 +866,125 @@ impl From<&PropSource> for PropOrigin {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State types for inventory item state management
|
||||
// ============================================================================
|
||||
|
||||
/// Scope of state visibility for inventory items.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StateScope {
|
||||
/// Server-wide scope: visible to everyone.
|
||||
Server,
|
||||
/// Realm scope: visible only to realm members.
|
||||
Realm,
|
||||
/// User scope: visible to those inspecting the item.
|
||||
User,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StateScope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StateScope::Server => write!(f, "server"),
|
||||
StateScope::Realm => write!(f, "realm"),
|
||||
StateScope::User => write!(f, "user"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility of state: public or private.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StateVisibility {
|
||||
/// Public state: persists through transfer, visible based on scope.
|
||||
Public,
|
||||
/// Private state: cleared on transfer, only visible to owner.
|
||||
Private,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for StateVisibility {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
StateVisibility::Public => write!(f, "public"),
|
||||
StateVisibility::Private => write!(f, "private"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hint for how to render/handle a prop's state (detected from state content).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PropActionHint {
|
||||
/// Render as a business card with profile info and social links.
|
||||
BusinessCard,
|
||||
/// Render as an info panel with formatted JSON.
|
||||
InfoPanel,
|
||||
/// Render as a list of external links/buttons.
|
||||
ExternalLinks,
|
||||
}
|
||||
|
||||
/// View of prop state filtered by viewer permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PropStateView {
|
||||
/// The prop ID (inventory item or loose prop).
|
||||
pub prop_id: Uuid,
|
||||
/// The prop name.
|
||||
pub prop_name: String,
|
||||
/// Display name of the owner (if known).
|
||||
pub owner_display_name: Option<String>,
|
||||
/// Server-scoped public state (visible to everyone).
|
||||
pub server_state: serde_json::Value,
|
||||
/// Realm-scoped public state (None if viewer not in same realm).
|
||||
pub realm_state: Option<serde_json::Value>,
|
||||
/// User-scoped public state (visible when inspecting).
|
||||
pub user_state: serde_json::Value,
|
||||
/// Private state bundle (only included if viewer is owner).
|
||||
pub private_state: Option<PrivateStateBundle>,
|
||||
/// Detected action hint based on state content.
|
||||
pub action_hint: Option<PropActionHint>,
|
||||
}
|
||||
|
||||
/// Bundle of private state (only visible to owner).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivateStateBundle {
|
||||
pub server_private_state: serde_json::Value,
|
||||
pub realm_private_state: serde_json::Value,
|
||||
pub user_private_state: serde_json::Value,
|
||||
}
|
||||
|
||||
impl PropStateView {
|
||||
/// Detect action hint from state content.
|
||||
pub fn detect_action_hint(server_state: &serde_json::Value) -> Option<PropActionHint> {
|
||||
use serde_json::Value;
|
||||
|
||||
// Check for business card pattern (profile with social links)
|
||||
if let Some(profile) = server_state.get("profile") {
|
||||
let profile: &Value = profile;
|
||||
if profile.get("linkedin").is_some()
|
||||
|| profile.get("github").is_some()
|
||||
|| profile.get("twitter").is_some()
|
||||
|| profile.get("website").is_some()
|
||||
{
|
||||
return Some(PropActionHint::BusinessCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for external links pattern
|
||||
if server_state.get("links").is_some() {
|
||||
return Some(PropActionHint::ExternalLinks);
|
||||
}
|
||||
|
||||
// If there's any non-empty state, show as info panel
|
||||
if let Some(obj) = server_state.as_object() {
|
||||
if !obj.is_empty() {
|
||||
return Some(PropActionHint::InfoPanel);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// An inventory item (user-owned prop).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
|
|
@ -882,6 +1001,28 @@ pub struct InventoryItem {
|
|||
/// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload)
|
||||
pub origin: PropOrigin,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
|
||||
// Public state columns (persist through transfer)
|
||||
/// Server-scoped state visible to everyone.
|
||||
#[serde(default)]
|
||||
pub server_state: serde_json::Value,
|
||||
/// Realm-scoped state visible to realm members.
|
||||
#[serde(default)]
|
||||
pub realm_state: serde_json::Value,
|
||||
/// User-scoped state visible to item inspectors.
|
||||
#[serde(default)]
|
||||
pub user_state: serde_json::Value,
|
||||
|
||||
// Private state columns (cleared on transfer)
|
||||
/// Private server-scoped state, only visible to owner.
|
||||
#[serde(default)]
|
||||
pub server_private_state: serde_json::Value,
|
||||
/// Private realm-scoped state, only visible to owner.
|
||||
#[serde(default)]
|
||||
pub realm_private_state: serde_json::Value,
|
||||
/// Private user-scoped state, only visible to owner.
|
||||
#[serde(default)]
|
||||
pub user_private_state: serde_json::Value,
|
||||
}
|
||||
|
||||
impl InventoryItem {
|
||||
|
|
@ -960,6 +1101,10 @@ pub struct LoosePropRow {
|
|||
pub prop_asset_path: String,
|
||||
pub is_locked: bool,
|
||||
pub locked_by: Option<Uuid>,
|
||||
// Public state columns (loose props only have public state)
|
||||
pub server_state: serde_json::Value,
|
||||
pub realm_state: serde_json::Value,
|
||||
pub user_state: serde_json::Value,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
|
|
@ -989,6 +1134,9 @@ impl From<LoosePropRow> for LooseProp {
|
|||
prop_asset_path: row.prop_asset_path,
|
||||
is_locked: row.is_locked,
|
||||
locked_by: row.locked_by,
|
||||
server_state: row.server_state,
|
||||
realm_state: row.realm_state,
|
||||
user_state: row.user_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1017,6 +1165,16 @@ pub struct LooseProp {
|
|||
/// User ID of the moderator who locked this prop.
|
||||
#[serde(default)]
|
||||
pub locked_by: Option<Uuid>,
|
||||
// Public state columns (loose props only have public state, private is cleared on drop)
|
||||
/// Server-scoped state visible to everyone.
|
||||
#[serde(default)]
|
||||
pub server_state: serde_json::Value,
|
||||
/// Realm-scoped state visible to realm members.
|
||||
#[serde(default)]
|
||||
pub realm_state: serde_json::Value,
|
||||
/// User-scoped state from previous owner.
|
||||
#[serde(default)]
|
||||
pub user_state: serde_json::Value,
|
||||
}
|
||||
|
||||
/// A server-wide prop (global library).
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ pub async fn list_user_inventory<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
FROM auth.inventory
|
||||
WHERE user_id = $1
|
||||
ORDER BY acquired_at DESC
|
||||
|
|
@ -251,7 +257,13 @@ pub async fn acquire_server_prop<'e>(
|
|||
origin,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
is_droppable
|
||||
is_droppable,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT
|
||||
$2,
|
||||
|
|
@ -262,7 +274,13 @@ pub async fn acquire_server_prop<'e>(
|
|||
'server_library'::server.prop_origin,
|
||||
oc.is_transferable,
|
||||
oc.is_portable,
|
||||
oc.is_droppable
|
||||
oc.is_droppable,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
FROM ownership_check oc
|
||||
WHERE oc.is_active = true
|
||||
AND oc.is_public = true
|
||||
|
|
@ -280,7 +298,13 @@ pub async fn acquire_server_prop<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT * FROM inserted
|
||||
"#,
|
||||
|
|
@ -428,7 +452,13 @@ pub async fn acquire_realm_prop<'e>(
|
|||
origin,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
is_droppable
|
||||
is_droppable,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
|
|
@ -439,7 +469,13 @@ pub async fn acquire_realm_prop<'e>(
|
|||
'realm_library'::server.prop_origin,
|
||||
oc.is_transferable,
|
||||
true, -- realm props are portable by default
|
||||
oc.is_droppable
|
||||
oc.is_droppable,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
FROM ownership_check oc
|
||||
WHERE oc.is_active = true
|
||||
AND oc.is_public = true
|
||||
|
|
@ -457,7 +493,13 @@ pub async fn acquire_realm_prop<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT * FROM inserted
|
||||
"#,
|
||||
|
|
@ -547,3 +589,103 @@ pub async fn get_realm_prop_acquisition_error<'e>(
|
|||
)),
|
||||
}
|
||||
}
|
||||
|
||||
use crate::models::{StateScope, StateVisibility};
|
||||
|
||||
/// Update state on an inventory item.
|
||||
///
|
||||
/// Only the item owner can update state.
|
||||
/// If `merge` is true, the new state is deep-merged with existing state.
|
||||
/// If `merge` is false, the new state replaces existing state.
|
||||
///
|
||||
/// Returns the updated state value.
|
||||
pub async fn update_inventory_item_state<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
user_id: Uuid,
|
||||
item_id: Uuid,
|
||||
scope: StateScope,
|
||||
visibility: StateVisibility,
|
||||
state: serde_json::Value,
|
||||
merge: bool,
|
||||
) -> Result<serde_json::Value, AppError> {
|
||||
// Determine which column to update based on scope and visibility
|
||||
let column_name = match (scope, visibility) {
|
||||
(StateScope::Server, StateVisibility::Public) => "server_state",
|
||||
(StateScope::Server, StateVisibility::Private) => "server_private_state",
|
||||
(StateScope::Realm, StateVisibility::Public) => "realm_state",
|
||||
(StateScope::Realm, StateVisibility::Private) => "realm_private_state",
|
||||
(StateScope::User, StateVisibility::Public) => "user_state",
|
||||
(StateScope::User, StateVisibility::Private) => "user_private_state",
|
||||
};
|
||||
|
||||
// Build the update expression based on merge flag
|
||||
// If merge is true, use jsonb_strip_nulls(old || new) for deep merge
|
||||
// If merge is false, just replace with new value
|
||||
let update_expr = if merge {
|
||||
format!("{} = jsonb_strip_nulls({} || $3)", column_name, column_name)
|
||||
} else {
|
||||
format!("{} = $3", column_name)
|
||||
};
|
||||
|
||||
// Build the full query
|
||||
let query = format!(
|
||||
r#"
|
||||
UPDATE auth.inventory
|
||||
SET {}
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING {}
|
||||
"#,
|
||||
update_expr, column_name
|
||||
);
|
||||
|
||||
let result: Option<(serde_json::Value,)> = sqlx::query_as(&query)
|
||||
.bind(item_id)
|
||||
.bind(user_id)
|
||||
.bind(&state)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
Some((new_state,)) => Ok(new_state),
|
||||
None => Err(AppError::NotFound(
|
||||
"Inventory item not found or not owned by user".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get an inventory item by ID with ownership verification.
|
||||
pub async fn get_inventory_item<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
item_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<InventoryItem>, AppError> {
|
||||
let item = sqlx::query_as::<_, InventoryItem>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(server_prop_id, realm_prop_id) as prop_id,
|
||||
prop_name,
|
||||
prop_asset_path,
|
||||
layer,
|
||||
is_transferable,
|
||||
is_portable,
|
||||
is_droppable,
|
||||
origin,
|
||||
acquired_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
FROM auth.inventory
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(item_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ pub async fn list_channel_loose_props<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
lp.is_locked,
|
||||
lp.locked_by
|
||||
lp.locked_by,
|
||||
lp.server_state,
|
||||
lp.realm_state,
|
||||
lp.user_state
|
||||
FROM scene.loose_props lp
|
||||
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
|
||||
|
|
@ -69,11 +72,37 @@ pub async fn list_channel_loose_props<'e>(
|
|||
Ok(rows.into_iter().map(LooseProp::from).collect())
|
||||
}
|
||||
|
||||
/// Result row type for drop_prop_to_canvas query.
|
||||
/// Uses a struct instead of tuple because sqlx has a limit on tuple size.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct DropPropResult {
|
||||
item_existed: bool,
|
||||
was_droppable: bool,
|
||||
was_deleted: bool,
|
||||
id: Option<Uuid>,
|
||||
channel_id: Option<Uuid>,
|
||||
server_prop_id: Option<Uuid>,
|
||||
realm_prop_id: Option<Uuid>,
|
||||
position_x: Option<f32>,
|
||||
position_y: Option<f32>,
|
||||
scale: Option<f32>,
|
||||
dropped_by: Option<Uuid>,
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
prop_name: Option<String>,
|
||||
prop_asset_path: Option<String>,
|
||||
server_state: Option<serde_json::Value>,
|
||||
realm_state: Option<serde_json::Value>,
|
||||
user_state: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Drop a prop from inventory to the canvas.
|
||||
///
|
||||
/// Deletes from inventory and inserts into loose_props with 30-minute expiry.
|
||||
/// Returns the created loose prop.
|
||||
/// Returns an error if the prop is non-droppable (essential prop).
|
||||
/// Note: Public state (server_state, realm_state, user_state) is transferred to the loose prop.
|
||||
/// Private state is NOT transferred (cleared when dropped).
|
||||
pub async fn drop_prop_to_canvas<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
inventory_item_id: Uuid,
|
||||
|
|
@ -85,23 +114,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
// Single CTE that checks existence/droppability and performs the operation atomically.
|
||||
// Returns status flags plus the LooseProp data (if successful).
|
||||
// Includes scale inherited from the source prop's default_scale.
|
||||
let result: Option<(
|
||||
bool,
|
||||
bool,
|
||||
bool,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<Uuid>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
Option<f32>,
|
||||
Option<Uuid>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<chrono::DateTime<chrono::Utc>>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
// Transfers public state columns (server_state, realm_state, user_state).
|
||||
let result: Option<DropPropResult> = sqlx::query_as(
|
||||
r#"
|
||||
WITH item_info AS (
|
||||
SELECT
|
||||
|
|
@ -111,6 +125,9 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
inv.realm_prop_id,
|
||||
inv.prop_name,
|
||||
inv.prop_asset_path,
|
||||
inv.server_state,
|
||||
inv.realm_state,
|
||||
inv.user_state,
|
||||
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale
|
||||
FROM auth.inventory inv
|
||||
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||
|
|
@ -120,7 +137,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
deleted_item AS (
|
||||
DELETE FROM auth.inventory
|
||||
WHERE id = $1 AND user_id = $2 AND is_droppable = true
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path,
|
||||
server_state, realm_state, user_state
|
||||
),
|
||||
inserted_prop AS (
|
||||
INSERT INTO scene.loose_props (
|
||||
|
|
@ -130,7 +148,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
position,
|
||||
scale,
|
||||
dropped_by,
|
||||
expires_at
|
||||
expires_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
|
|
@ -139,7 +160,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
public.make_virtual_point($4::real, $5::real),
|
||||
(SELECT default_scale FROM item_info),
|
||||
$2,
|
||||
now() + interval '30 minutes'
|
||||
now() + interval '30 minutes',
|
||||
di.server_state,
|
||||
di.realm_state,
|
||||
di.user_state
|
||||
FROM deleted_item di
|
||||
RETURNING
|
||||
id,
|
||||
|
|
@ -151,7 +175,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
scale,
|
||||
dropped_by,
|
||||
expires_at,
|
||||
created_at
|
||||
created_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM item_info) AS item_existed,
|
||||
|
|
@ -168,7 +195,10 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
ip.expires_at,
|
||||
ip.created_at,
|
||||
di.prop_name,
|
||||
di.prop_asset_path
|
||||
di.prop_asset_path,
|
||||
ip.server_state,
|
||||
ip.realm_state,
|
||||
ip.user_state
|
||||
FROM (SELECT 1) AS dummy
|
||||
LEFT JOIN inserted_prop ip ON true
|
||||
LEFT JOIN deleted_item di ON true
|
||||
|
|
@ -189,41 +219,44 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
"Unexpected error dropping prop to canvas".to_string(),
|
||||
))
|
||||
}
|
||||
Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||
Some(r) if !r.item_existed => {
|
||||
// Item didn't exist
|
||||
Err(AppError::NotFound(
|
||||
"Inventory item not found or not owned by user".to_string(),
|
||||
))
|
||||
}
|
||||
Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||
Some(r) if r.item_existed && !r.was_droppable => {
|
||||
// Item existed but is not droppable
|
||||
Err(AppError::Forbidden(
|
||||
"This prop cannot be dropped - it is an essential prop".to_string(),
|
||||
))
|
||||
}
|
||||
Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => {
|
||||
Some(r) if r.item_existed && r.was_droppable && !r.was_deleted => {
|
||||
// Item was droppable but delete failed (shouldn't happen)
|
||||
Err(AppError::Internal(
|
||||
"Unexpected error dropping prop to canvas".to_string(),
|
||||
))
|
||||
}
|
||||
Some((
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
Some(id),
|
||||
Some(channel_id),
|
||||
Some(DropPropResult {
|
||||
item_existed: true,
|
||||
was_droppable: true,
|
||||
was_deleted: true,
|
||||
id: Some(id),
|
||||
channel_id: Some(channel_id),
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
Some(position_x),
|
||||
Some(position_y),
|
||||
Some(scale),
|
||||
position_x: Some(position_x),
|
||||
position_y: Some(position_y),
|
||||
scale: Some(scale),
|
||||
dropped_by,
|
||||
Some(expires_at),
|
||||
Some(created_at),
|
||||
Some(prop_name),
|
||||
Some(prop_asset_path),
|
||||
)) => {
|
||||
expires_at: Some(expires_at),
|
||||
created_at: Some(created_at),
|
||||
prop_name: Some(prop_name),
|
||||
prop_asset_path: Some(prop_asset_path),
|
||||
server_state: Some(server_state),
|
||||
realm_state: Some(realm_state),
|
||||
user_state: Some(user_state),
|
||||
}) => {
|
||||
// Construct PropSource from the nullable columns
|
||||
let source = if let Some(sid) = server_prop_id {
|
||||
PropSource::Server(sid)
|
||||
|
|
@ -250,6 +283,9 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
prop_asset_path,
|
||||
is_locked: false,
|
||||
locked_by: None,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -264,19 +300,23 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
/// Pick up a loose prop (delete from loose_props, insert to inventory).
|
||||
///
|
||||
/// Returns the created inventory item.
|
||||
/// Note: Public state (server_state, realm_state, user_state) is transferred from the loose prop.
|
||||
/// Private state is initialized to empty (cleared for new owner).
|
||||
pub async fn pick_up_loose_prop<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
loose_prop_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<InventoryItem, AppError> {
|
||||
// Use a CTE to delete from loose_props and insert to inventory
|
||||
// Public state is transferred, private state is initialized to empty
|
||||
let item = sqlx::query_as::<_, InventoryItem>(
|
||||
r#"
|
||||
WITH deleted_prop AS (
|
||||
DELETE FROM scene.loose_props
|
||||
WHERE id = $1
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
RETURNING id, server_prop_id, realm_prop_id
|
||||
RETURNING id, server_prop_id, realm_prop_id,
|
||||
server_state, realm_state, user_state
|
||||
),
|
||||
source_info AS (
|
||||
SELECT
|
||||
|
|
@ -287,7 +327,10 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
COALESCE(sp.is_portable, true) as is_portable,
|
||||
COALESCE(sp.is_droppable, rp.is_droppable, true) as is_droppable,
|
||||
dp.server_prop_id,
|
||||
dp.realm_prop_id
|
||||
dp.realm_prop_id,
|
||||
dp.server_state,
|
||||
dp.realm_state,
|
||||
dp.user_state
|
||||
FROM deleted_prop dp
|
||||
LEFT JOIN server.props sp ON dp.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id
|
||||
|
|
@ -305,7 +348,15 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
is_portable,
|
||||
is_droppable,
|
||||
provenance,
|
||||
acquired_at
|
||||
acquired_at,
|
||||
-- Transfer public state from loose prop
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
-- Initialize private state to empty (cleared for new owner)
|
||||
server_private_state,
|
||||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT
|
||||
$2,
|
||||
|
|
@ -319,9 +370,20 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
COALESCE(si.is_portable, true),
|
||||
COALESCE(si.is_droppable, true),
|
||||
'[]'::jsonb,
|
||||
now()
|
||||
now(),
|
||||
-- Transfer public state
|
||||
si.server_state,
|
||||
si.realm_state,
|
||||
si.user_state,
|
||||
-- Private state cleared for new owner
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
FROM source_info si
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, origin, acquired_at
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer,
|
||||
is_transferable, is_portable, is_droppable, origin, acquired_at,
|
||||
server_state, realm_state, user_state,
|
||||
server_private_state, realm_private_state, user_private_state
|
||||
)
|
||||
SELECT
|
||||
ii.id,
|
||||
|
|
@ -333,7 +395,13 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
ii.is_portable,
|
||||
ii.is_droppable,
|
||||
ii.origin,
|
||||
ii.acquired_at
|
||||
ii.acquired_at,
|
||||
ii.server_state,
|
||||
ii.realm_state,
|
||||
ii.user_state,
|
||||
ii.server_private_state,
|
||||
ii.realm_private_state,
|
||||
ii.user_private_state
|
||||
FROM inserted_item ii
|
||||
"#,
|
||||
)
|
||||
|
|
@ -381,7 +449,10 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -397,7 +468,10 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
@ -433,7 +507,10 @@ pub async fn get_loose_prop_by_id<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
lp.is_locked,
|
||||
lp.locked_by
|
||||
lp.locked_by,
|
||||
lp.server_state,
|
||||
lp.realm_state,
|
||||
lp.user_state
|
||||
FROM scene.loose_props lp
|
||||
LEFT JOIN server.props sp ON lp.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id
|
||||
|
|
@ -474,7 +551,10 @@ pub async fn move_loose_prop<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -490,7 +570,10 @@ pub async fn move_loose_prop<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
@ -531,7 +614,10 @@ pub async fn lock_loose_prop<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -547,7 +633,10 @@ pub async fn lock_loose_prop<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
@ -586,7 +675,10 @@ pub async fn unlock_loose_prop<'e>(
|
|||
expires_at,
|
||||
created_at,
|
||||
is_locked,
|
||||
locked_by
|
||||
locked_by,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
|
|
@ -602,7 +694,10 @@ pub async fn unlock_loose_prop<'e>(
|
|||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by
|
||||
u.locked_by,
|
||||
u.server_state,
|
||||
u.realm_state,
|
||||
u.user_state
|
||||
FROM updated u
|
||||
LEFT JOIN server.props sp ON u.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp};
|
||||
use crate::models::{
|
||||
AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp,
|
||||
PropStateView, StateScope, StateVisibility,
|
||||
};
|
||||
|
||||
/// Default function for serde that returns true (for is_same_scene field).
|
||||
/// Must be pub for serde derive macro to access via full path.
|
||||
|
|
@ -146,6 +149,29 @@ pub enum ClientMessage {
|
|||
/// The loose prop ID to delete.
|
||||
loose_prop_id: Uuid,
|
||||
},
|
||||
|
||||
/// Update state on an inventory item.
|
||||
UpdateItemState {
|
||||
/// The inventory item ID to update.
|
||||
inventory_item_id: Uuid,
|
||||
/// Which scope to update (server, realm, or user).
|
||||
scope: StateScope,
|
||||
/// Whether to update public or private state.
|
||||
visibility: StateVisibility,
|
||||
/// The new state value.
|
||||
state: serde_json::Value,
|
||||
/// If true, merge with existing state; if false, replace.
|
||||
#[serde(default)]
|
||||
merge: bool,
|
||||
},
|
||||
|
||||
/// Request to view a prop's state.
|
||||
ViewPropState {
|
||||
/// The prop ID to view (either inventory item or loose prop).
|
||||
prop_id: Uuid,
|
||||
/// Whether this is a loose prop (true) or inventory item (false).
|
||||
is_loose_prop: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Server-to-client WebSocket messages.
|
||||
|
|
@ -348,4 +374,22 @@ pub enum ServerMessage {
|
|||
/// Display name of who cleared the forced avatar (if mod command).
|
||||
cleared_by: Option<String>,
|
||||
},
|
||||
|
||||
/// Response with prop state for viewing.
|
||||
PropStateViewResponse {
|
||||
/// The prop state view data.
|
||||
view: PropStateView,
|
||||
},
|
||||
|
||||
/// Confirmation of item state update.
|
||||
ItemStateUpdated {
|
||||
/// The inventory item ID that was updated.
|
||||
inventory_item_id: Uuid,
|
||||
/// Which scope was updated.
|
||||
scope: StateScope,
|
||||
/// Whether public or private state was updated.
|
||||
visibility: StateVisibility,
|
||||
/// The new state value.
|
||||
new_state: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue