feat: profiles and /set profile, and id cards
* New functionality to set meta data on businesscards. * Can develop a user profile. * Business cards link to user profile.
This commit is contained in:
parent
cd8dfb94a3
commit
710985638f
35 changed files with 4932 additions and 435 deletions
|
|
@ -8,6 +8,7 @@ chattyness-error = { workspace = true, optional = true }
|
|||
chattyness-shared = { workspace = true, optional = true }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -581,6 +581,182 @@ impl std::str::FromStr for AgeCategory {
|
|||
}
|
||||
}
|
||||
|
||||
/// Avatar source for profile pictures.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[derive(strum::Display, strum::EnumString)]
|
||||
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||
#[cfg_attr(
|
||||
feature = "ssr",
|
||||
sqlx(type_name = "avatar_source", rename_all = "snake_case")
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AvatarSource {
|
||||
#[default]
|
||||
Local,
|
||||
Discord,
|
||||
Github,
|
||||
GoogleScholar,
|
||||
Libravatar,
|
||||
Gravatar,
|
||||
}
|
||||
|
||||
/// Profile visibility levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[derive(strum::Display, strum::EnumString)]
|
||||
#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||
#[cfg_attr(
|
||||
feature = "ssr",
|
||||
sqlx(type_name = "profile_visibility", rename_all = "lowercase")
|
||||
)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProfileVisibility {
|
||||
#[default]
|
||||
Public,
|
||||
Members,
|
||||
Friends,
|
||||
Private,
|
||||
}
|
||||
|
||||
/// Contact/social platform identifiers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(strum::Display, strum::EnumString)]
|
||||
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::Type))]
|
||||
#[cfg_attr(
|
||||
feature = "ssr",
|
||||
sqlx(type_name = "contact_platform", rename_all = "snake_case")
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContactPlatform {
|
||||
// Social
|
||||
Discord,
|
||||
Linkedin,
|
||||
Facebook,
|
||||
Twitter,
|
||||
Instagram,
|
||||
Threads,
|
||||
Pinterest,
|
||||
// Media
|
||||
Youtube,
|
||||
Spotify,
|
||||
Substack,
|
||||
Patreon,
|
||||
Linktree,
|
||||
// Development
|
||||
Github,
|
||||
Gitlab,
|
||||
Gitea,
|
||||
Codeberg,
|
||||
Stackexchange,
|
||||
CratesIo,
|
||||
PauseCpan,
|
||||
Npm,
|
||||
Devpost,
|
||||
Huggingface,
|
||||
// Academic
|
||||
GoogleScholar,
|
||||
Wikidata,
|
||||
WikimediaCommons,
|
||||
Wikipedia,
|
||||
// Other
|
||||
Steam,
|
||||
AmazonAuthor,
|
||||
Openstreetmap,
|
||||
Phone,
|
||||
EmailAlt,
|
||||
Website,
|
||||
}
|
||||
|
||||
impl ContactPlatform {
|
||||
/// Get the display label for this platform.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ContactPlatform::Discord => "Discord",
|
||||
ContactPlatform::Linkedin => "LinkedIn",
|
||||
ContactPlatform::Facebook => "Facebook",
|
||||
ContactPlatform::Twitter => "Twitter/X",
|
||||
ContactPlatform::Instagram => "Instagram",
|
||||
ContactPlatform::Threads => "Threads",
|
||||
ContactPlatform::Pinterest => "Pinterest",
|
||||
ContactPlatform::Youtube => "YouTube",
|
||||
ContactPlatform::Spotify => "Spotify",
|
||||
ContactPlatform::Substack => "Substack",
|
||||
ContactPlatform::Patreon => "Patreon",
|
||||
ContactPlatform::Linktree => "Linktree",
|
||||
ContactPlatform::Github => "GitHub",
|
||||
ContactPlatform::Gitlab => "GitLab",
|
||||
ContactPlatform::Gitea => "Gitea",
|
||||
ContactPlatform::Codeberg => "Codeberg",
|
||||
ContactPlatform::Stackexchange => "Stack Exchange",
|
||||
ContactPlatform::CratesIo => "crates.io",
|
||||
ContactPlatform::PauseCpan => "PAUSE/CPAN",
|
||||
ContactPlatform::Npm => "npm",
|
||||
ContactPlatform::Devpost => "Devpost",
|
||||
ContactPlatform::Huggingface => "Hugging Face",
|
||||
ContactPlatform::GoogleScholar => "Google Scholar",
|
||||
ContactPlatform::Wikidata => "Wikidata",
|
||||
ContactPlatform::WikimediaCommons => "Wikimedia Commons",
|
||||
ContactPlatform::Wikipedia => "Wikipedia",
|
||||
ContactPlatform::Steam => "Steam",
|
||||
ContactPlatform::AmazonAuthor => "Amazon Author",
|
||||
ContactPlatform::Openstreetmap => "OpenStreetMap",
|
||||
ContactPlatform::Phone => "Phone",
|
||||
ContactPlatform::EmailAlt => "Email",
|
||||
ContactPlatform::Website => "Website",
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all platforms grouped by category.
|
||||
pub fn grouped() -> Vec<(&'static str, Vec<ContactPlatform>)> {
|
||||
vec![
|
||||
("Social", vec![
|
||||
ContactPlatform::Discord,
|
||||
ContactPlatform::Linkedin,
|
||||
ContactPlatform::Facebook,
|
||||
ContactPlatform::Twitter,
|
||||
ContactPlatform::Instagram,
|
||||
ContactPlatform::Threads,
|
||||
ContactPlatform::Pinterest,
|
||||
]),
|
||||
("Media", vec![
|
||||
ContactPlatform::Youtube,
|
||||
ContactPlatform::Spotify,
|
||||
ContactPlatform::Substack,
|
||||
ContactPlatform::Patreon,
|
||||
ContactPlatform::Linktree,
|
||||
]),
|
||||
("Development", vec![
|
||||
ContactPlatform::Github,
|
||||
ContactPlatform::Gitlab,
|
||||
ContactPlatform::Gitea,
|
||||
ContactPlatform::Codeberg,
|
||||
ContactPlatform::Stackexchange,
|
||||
ContactPlatform::CratesIo,
|
||||
ContactPlatform::PauseCpan,
|
||||
ContactPlatform::Npm,
|
||||
ContactPlatform::Devpost,
|
||||
ContactPlatform::Huggingface,
|
||||
]),
|
||||
("Academic", vec![
|
||||
ContactPlatform::GoogleScholar,
|
||||
ContactPlatform::Wikidata,
|
||||
ContactPlatform::WikimediaCommons,
|
||||
ContactPlatform::Wikipedia,
|
||||
]),
|
||||
("Other", vec![
|
||||
ContactPlatform::Steam,
|
||||
ContactPlatform::AmazonAuthor,
|
||||
ContactPlatform::Openstreetmap,
|
||||
ContactPlatform::Phone,
|
||||
ContactPlatform::EmailAlt,
|
||||
ContactPlatform::Website,
|
||||
]),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// User Models
|
||||
// =============================================================================
|
||||
|
|
@ -625,6 +801,256 @@ pub struct UserSummary {
|
|||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Full user profile for editing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct UserProfile {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: String,
|
||||
pub name_first: Option<String>,
|
||||
pub name_last: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_source: AvatarSource,
|
||||
pub profile_visibility: ProfileVisibility,
|
||||
pub contacts_visibility: ProfileVisibility,
|
||||
pub organizations_visibility: ProfileVisibility,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Public profile view - used for displaying profile to others.
|
||||
/// Fields are optional because visibility settings may hide them.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicProfile {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub display_name: String,
|
||||
pub summary: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_source: AvatarSource,
|
||||
pub contacts: Option<Vec<UserContact>>,
|
||||
pub organizations: Option<Vec<UserOrganization>>,
|
||||
pub member_since: DateTime<Utc>,
|
||||
/// Whether the viewer is the profile owner (can edit)
|
||||
pub is_owner: bool,
|
||||
}
|
||||
|
||||
/// A user's contact/social link.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct UserContact {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub platform: ContactPlatform,
|
||||
pub value: String,
|
||||
pub label: Option<String>,
|
||||
pub sort_order: i16,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A user's organization affiliation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
pub struct UserOrganization {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub start_date: Option<NaiveDate>,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub is_current: bool,
|
||||
pub sort_order: i16,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Request to update basic profile fields.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub display_name: String,
|
||||
pub name_first: Option<String>,
|
||||
pub name_last: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl UpdateProfileRequest {
|
||||
/// Validate the update profile request.
|
||||
pub fn validate(&self) -> Result<(), AppError> {
|
||||
validation::validate_non_empty(&self.display_name, "Display name")?;
|
||||
validation::validate_length(&self.display_name, "Display name", 1, 50)?;
|
||||
if let Some(ref summary) = self.summary {
|
||||
validation::validate_length(summary, "Summary", 0, 200)?;
|
||||
}
|
||||
if let Some(ref homepage) = self.homepage {
|
||||
if !homepage.is_empty() {
|
||||
validation::validate_length(homepage, "Homepage URL", 0, 500)?;
|
||||
// Basic URL validation: must start with http:// or https://
|
||||
if !homepage.starts_with("http://") && !homepage.starts_with("https://") {
|
||||
return Err(AppError::Validation("Homepage must be a valid URL starting with http:// or https://".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref bio) = self.bio {
|
||||
validation::validate_length(bio, "Bio", 0, 2000)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to update visibility settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateVisibilityRequest {
|
||||
pub profile_visibility: ProfileVisibility,
|
||||
pub contacts_visibility: ProfileVisibility,
|
||||
pub organizations_visibility: ProfileVisibility,
|
||||
pub avatar_source: AvatarSource,
|
||||
}
|
||||
|
||||
/// Request to create a new contact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateContactRequest {
|
||||
pub platform: ContactPlatform,
|
||||
pub value: String,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Request to update an existing contact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateContactRequest {
|
||||
pub platform: ContactPlatform,
|
||||
pub value: String,
|
||||
pub label: Option<String>,
|
||||
pub sort_order: i16,
|
||||
}
|
||||
|
||||
/// Validate contact fields (shared between create and update).
|
||||
#[cfg(feature = "ssr")]
|
||||
fn validate_contact_fields(value: &str, label: Option<&str>) -> Result<(), AppError> {
|
||||
validation::validate_non_empty(value, "Contact value")?;
|
||||
validation::validate_length(value, "Contact value", 1, 500)?;
|
||||
if let Some(l) = label {
|
||||
validation::validate_length(l, "Label", 0, 100)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl CreateContactRequest {
|
||||
/// Validate the create contact request.
|
||||
pub fn validate(&self) -> Result<(), AppError> {
|
||||
validate_contact_fields(&self.value, self.label.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl UpdateContactRequest {
|
||||
/// Validate the update contact request.
|
||||
pub fn validate(&self) -> Result<(), AppError> {
|
||||
validate_contact_fields(&self.value, self.label.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to create a new organization affiliation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateOrganizationRequest {
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub start_date: Option<NaiveDate>,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub is_current: bool,
|
||||
}
|
||||
|
||||
/// Request to update an existing organization affiliation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateOrganizationRequest {
|
||||
pub wikidata_qid: Option<String>,
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub department: Option<String>,
|
||||
pub start_date: Option<NaiveDate>,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub is_current: bool,
|
||||
pub sort_order: i16,
|
||||
}
|
||||
|
||||
/// Validate organization fields (shared between create and update).
|
||||
#[cfg(feature = "ssr")]
|
||||
fn validate_organization_fields(
|
||||
name: &str,
|
||||
role: Option<&str>,
|
||||
department: Option<&str>,
|
||||
wikidata_qid: Option<&str>,
|
||||
start_date: Option<NaiveDate>,
|
||||
end_date: Option<NaiveDate>,
|
||||
) -> Result<(), AppError> {
|
||||
validation::validate_non_empty(name, "Organization name")?;
|
||||
validation::validate_length(name, "Organization name", 1, 200)?;
|
||||
if let Some(r) = role {
|
||||
validation::validate_length(r, "Role", 0, 100)?;
|
||||
}
|
||||
if let Some(d) = department {
|
||||
validation::validate_length(d, "Department", 0, 100)?;
|
||||
}
|
||||
if let Some(qid) = wikidata_qid {
|
||||
if !qid.is_empty() && !qid.starts_with('Q') {
|
||||
return Err(AppError::Validation("Wikidata QID must start with 'Q'".to_string()));
|
||||
}
|
||||
}
|
||||
if let (Some(start), Some(end)) = (start_date, end_date) {
|
||||
if start > end {
|
||||
return Err(AppError::Validation("Start date must be before end date".to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl CreateOrganizationRequest {
|
||||
/// Validate the create organization request.
|
||||
pub fn validate(&self) -> Result<(), AppError> {
|
||||
validate_organization_fields(
|
||||
&self.name,
|
||||
self.role.as_deref(),
|
||||
self.department.as_deref(),
|
||||
self.wikidata_qid.as_deref(),
|
||||
self.start_date,
|
||||
self.end_date,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
impl UpdateOrganizationRequest {
|
||||
/// Validate the update organization request.
|
||||
pub fn validate(&self) -> Result<(), AppError> {
|
||||
validate_organization_fields(
|
||||
&self.name,
|
||||
self.role.as_deref(),
|
||||
self.department.as_deref(),
|
||||
self.wikidata_qid.as_deref(),
|
||||
self.start_date,
|
||||
self.end_date,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Realm Models
|
||||
// =============================================================================
|
||||
|
|
@ -955,16 +1381,40 @@ pub struct PrivateStateBundle {
|
|||
impl PropStateView {
|
||||
/// Detect action hint from state content.
|
||||
pub fn detect_action_hint(server_state: &serde_json::Value) -> Option<PropActionHint> {
|
||||
use serde_json::Value;
|
||||
Self::detect_action_hint_full(server_state, None, None)
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
/// Detect action hint from state content, including private state.
|
||||
pub fn detect_action_hint_with_private(
|
||||
server_state: &serde_json::Value,
|
||||
server_private_state: Option<&serde_json::Value>,
|
||||
) -> Option<PropActionHint> {
|
||||
Self::detect_action_hint_full(server_state, server_private_state, None)
|
||||
}
|
||||
|
||||
/// Detect action hint from state content, private state, and prop name.
|
||||
pub fn detect_action_hint_full(
|
||||
server_state: &serde_json::Value,
|
||||
server_private_state: Option<&serde_json::Value>,
|
||||
prop_name: Option<&str>,
|
||||
) -> Option<PropActionHint> {
|
||||
// Check for business card flag (is_owner_card: true)
|
||||
if let Some(is_owner) = server_state.get("is_owner_card") {
|
||||
if is_owner.as_bool() == Some(true) {
|
||||
return Some(PropActionHint::BusinessCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for business card snapshot in private state (received card)
|
||||
if let Some(private) = server_private_state {
|
||||
if private.get("snapshot").is_some() {
|
||||
return Some(PropActionHint::BusinessCard);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for business card name pattern (dropped cards: "Name's Business Card")
|
||||
if let Some(name) = prop_name {
|
||||
if name.ends_with("'s Business Card") {
|
||||
return Some(PropActionHint::BusinessCard);
|
||||
}
|
||||
}
|
||||
|
|
@ -985,6 +1435,24 @@ impl PropStateView {
|
|||
}
|
||||
}
|
||||
|
||||
/// Snapshot of a user's profile captured when a business card is transferred.
|
||||
///
|
||||
/// This captures the giver's profile at the moment they dropped the card,
|
||||
/// so the recipient can see the giver's information even if it changes later.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BusinessCardSnapshot {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub name_first: Option<String>,
|
||||
pub name_last: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub captured_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// An inventory item (user-owned prop).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||
|
|
@ -998,6 +1466,9 @@ pub struct InventoryItem {
|
|||
pub is_transferable: bool,
|
||||
pub is_portable: bool,
|
||||
pub is_droppable: bool,
|
||||
/// Whether this prop is unique (only one can exist in the world).
|
||||
/// Unique props cannot be copied via CopyAndDropProp.
|
||||
pub is_unique: bool,
|
||||
/// The source of this prop (ServerLibrary, RealmLibrary, or UserUpload)
|
||||
pub origin: PropOrigin,
|
||||
pub acquired_at: DateTime<Utc>,
|
||||
|
|
@ -2588,6 +3059,17 @@ impl UpdateServerConfigRequest {
|
|||
}
|
||||
}
|
||||
|
||||
/// Request to update server default avatars.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateServerDefaultAvatarsRequest {
|
||||
pub default_avatar_neutral_child: Option<Uuid>,
|
||||
pub default_avatar_neutral_adult: Option<Uuid>,
|
||||
pub default_avatar_male_child: Option<Uuid>,
|
||||
pub default_avatar_male_adult: Option<Uuid>,
|
||||
pub default_avatar_female_child: Option<Uuid>,
|
||||
pub default_avatar_female_adult: Option<Uuid>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Staff Models
|
||||
// =============================================================================
|
||||
|
|
@ -3362,6 +3844,8 @@ pub struct ChannelMemberInfo {
|
|||
pub user_id: Uuid,
|
||||
/// Display name (user's display_name)
|
||||
pub display_name: String,
|
||||
/// Username for profile URL
|
||||
pub username: String,
|
||||
/// X coordinate in scene space
|
||||
pub position_x: f64,
|
||||
/// Y coordinate in scene space
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ pub async fn get_channel_members<'e>(
|
|||
cm.instance_id as channel_id,
|
||||
cm.user_id,
|
||||
COALESCE(u.display_name, 'Anonymous') as display_name,
|
||||
COALESCE(u.username, 'anonymous') as username,
|
||||
ST_X(cm.position) as position_x,
|
||||
ST_Y(cm.position) as position_y,
|
||||
cm.facing_direction,
|
||||
|
|
@ -206,6 +207,7 @@ pub async fn get_channel_member<'e>(
|
|||
cm.instance_id as channel_id,
|
||||
cm.user_id,
|
||||
COALESCE(u.display_name, 'Anonymous') as display_name,
|
||||
COALESCE(u.username, 'anonymous') as username,
|
||||
ST_X(cm.position) as position_x,
|
||||
ST_Y(cm.position) as position_y,
|
||||
cm.facing_direction,
|
||||
|
|
|
|||
|
|
@ -14,25 +14,28 @@ pub async fn list_user_inventory<'e>(
|
|||
let items = 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 user_id = $1
|
||||
ORDER BY acquired_at DESC
|
||||
inv.id,
|
||||
COALESCE(inv.server_prop_id, inv.realm_prop_id) as prop_id,
|
||||
inv.prop_name,
|
||||
inv.prop_asset_path,
|
||||
inv.layer,
|
||||
inv.is_transferable,
|
||||
inv.is_portable,
|
||||
inv.is_droppable,
|
||||
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
|
||||
inv.origin,
|
||||
inv.acquired_at,
|
||||
inv.server_state,
|
||||
inv.realm_state,
|
||||
inv.user_state,
|
||||
inv.server_private_state,
|
||||
inv.realm_private_state,
|
||||
inv.user_private_state
|
||||
FROM auth.inventory inv
|
||||
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
|
||||
WHERE inv.user_id = $1
|
||||
ORDER BY inv.acquired_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
|
|
@ -205,6 +208,7 @@ pub async fn list_realm_props<'e>(
|
|||
/// - For unique props: checks no one owns it yet
|
||||
/// - For non-unique props: checks user doesn't already own it
|
||||
/// - Inserts into `auth.inventory` with `origin = server_library`
|
||||
/// - For business cards: renames to "My Business Card" and sets `is_owner_card` flag
|
||||
///
|
||||
/// Returns the created inventory item or an appropriate error.
|
||||
pub async fn acquire_server_prop<'e>(
|
||||
|
|
@ -213,6 +217,7 @@ pub async fn acquire_server_prop<'e>(
|
|||
user_id: Uuid,
|
||||
) -> Result<InventoryItem, AppError> {
|
||||
// Use a CTE to atomically check conditions and insert
|
||||
// Business cards are renamed to "My Business Card" and marked with is_owner_card flag
|
||||
let result: Option<InventoryItem> = sqlx::query_as(
|
||||
r#"
|
||||
WITH prop_check AS (
|
||||
|
|
@ -228,7 +233,8 @@ pub async fn acquire_server_prop<'e>(
|
|||
p.is_active,
|
||||
p.is_public,
|
||||
(p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok,
|
||||
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok
|
||||
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok,
|
||||
lower(p.name) LIKE '%businesscard%' AS is_business_card
|
||||
FROM server.props p
|
||||
WHERE p.id = $1
|
||||
),
|
||||
|
|
@ -268,14 +274,14 @@ pub async fn acquire_server_prop<'e>(
|
|||
SELECT
|
||||
$2,
|
||||
oc.id,
|
||||
oc.name,
|
||||
CASE WHEN oc.is_business_card THEN 'My Business Card' ELSE oc.name END,
|
||||
oc.asset_path,
|
||||
oc.default_layer,
|
||||
'server_library'::server.prop_origin,
|
||||
oc.is_transferable,
|
||||
oc.is_portable,
|
||||
oc.is_droppable,
|
||||
'{}'::jsonb,
|
||||
CASE WHEN oc.is_business_card THEN '{"is_owner_card": true}'::jsonb ELSE '{}'::jsonb END,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
|
|
@ -306,7 +312,26 @@ pub async fn acquire_server_prop<'e>(
|
|||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT * FROM inserted
|
||||
SELECT
|
||||
ins.id,
|
||||
ins.prop_id,
|
||||
ins.prop_name,
|
||||
ins.prop_asset_path,
|
||||
ins.layer,
|
||||
ins.is_transferable,
|
||||
ins.is_portable,
|
||||
ins.is_droppable,
|
||||
oc.is_unique,
|
||||
ins.origin,
|
||||
ins.acquired_at,
|
||||
ins.server_state,
|
||||
ins.realm_state,
|
||||
ins.user_state,
|
||||
ins.server_private_state,
|
||||
ins.realm_private_state,
|
||||
ins.user_private_state
|
||||
FROM inserted ins
|
||||
CROSS JOIN ownership_check oc
|
||||
"#,
|
||||
)
|
||||
.bind(prop_id)
|
||||
|
|
@ -400,6 +425,7 @@ pub async fn get_server_prop_acquisition_error<'e>(
|
|||
/// - For unique props: checks no one owns it yet
|
||||
/// - For non-unique props: checks user doesn't already own it
|
||||
/// - Inserts into `auth.inventory` with `origin = realm_library`
|
||||
/// - For business cards: renames to "My Business Card" and sets `is_owner_card` flag
|
||||
///
|
||||
/// Returns the created inventory item or an appropriate error.
|
||||
pub async fn acquire_realm_prop<'e>(
|
||||
|
|
@ -409,6 +435,7 @@ pub async fn acquire_realm_prop<'e>(
|
|||
user_id: Uuid,
|
||||
) -> Result<InventoryItem, AppError> {
|
||||
// Use a CTE to atomically check conditions and insert
|
||||
// Business cards are renamed to "My Business Card" and marked with is_owner_card flag
|
||||
let result: Option<InventoryItem> = sqlx::query_as(
|
||||
r#"
|
||||
WITH prop_check AS (
|
||||
|
|
@ -423,7 +450,8 @@ pub async fn acquire_realm_prop<'e>(
|
|||
p.is_active,
|
||||
p.is_public,
|
||||
(p.available_from IS NULL OR p.available_from <= now()) AS available_from_ok,
|
||||
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok
|
||||
(p.available_until IS NULL OR p.available_until > now()) AS available_until_ok,
|
||||
lower(p.name) LIKE '%businesscard%' AS is_business_card
|
||||
FROM realm.props p
|
||||
WHERE p.id = $1 AND p.realm_id = $2
|
||||
),
|
||||
|
|
@ -463,14 +491,14 @@ pub async fn acquire_realm_prop<'e>(
|
|||
SELECT
|
||||
$3,
|
||||
oc.id,
|
||||
oc.name,
|
||||
CASE WHEN oc.is_business_card THEN 'My Business Card' ELSE oc.name END,
|
||||
oc.asset_path,
|
||||
oc.default_layer,
|
||||
'realm_library'::server.prop_origin,
|
||||
oc.is_transferable,
|
||||
true, -- realm props are portable by default
|
||||
oc.is_droppable,
|
||||
'{}'::jsonb,
|
||||
CASE WHEN oc.is_business_card THEN '{"is_owner_card": true}'::jsonb ELSE '{}'::jsonb END,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb,
|
||||
|
|
@ -501,7 +529,26 @@ pub async fn acquire_realm_prop<'e>(
|
|||
realm_private_state,
|
||||
user_private_state
|
||||
)
|
||||
SELECT * FROM inserted
|
||||
SELECT
|
||||
ins.id,
|
||||
ins.prop_id,
|
||||
ins.prop_name,
|
||||
ins.prop_asset_path,
|
||||
ins.layer,
|
||||
ins.is_transferable,
|
||||
ins.is_portable,
|
||||
ins.is_droppable,
|
||||
oc.is_unique,
|
||||
ins.origin,
|
||||
ins.acquired_at,
|
||||
ins.server_state,
|
||||
ins.realm_state,
|
||||
ins.user_state,
|
||||
ins.server_private_state,
|
||||
ins.realm_private_state,
|
||||
ins.user_private_state
|
||||
FROM inserted ins
|
||||
CROSS JOIN ownership_check oc
|
||||
"#,
|
||||
)
|
||||
.bind(prop_id)
|
||||
|
|
@ -662,24 +709,27 @@ pub async fn get_inventory_item<'e>(
|
|||
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
|
||||
inv.id,
|
||||
COALESCE(inv.server_prop_id, inv.realm_prop_id) as prop_id,
|
||||
inv.prop_name,
|
||||
inv.prop_asset_path,
|
||||
inv.layer,
|
||||
inv.is_transferable,
|
||||
inv.is_portable,
|
||||
inv.is_droppable,
|
||||
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
|
||||
inv.origin,
|
||||
inv.acquired_at,
|
||||
inv.server_state,
|
||||
inv.realm_state,
|
||||
inv.user_state,
|
||||
inv.server_private_state,
|
||||
inv.realm_private_state,
|
||||
inv.user_private_state
|
||||
FROM auth.inventory inv
|
||||
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
|
||||
WHERE inv.id = $1 AND inv.user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(item_id)
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ pub async fn list_channel_loose_props<'e>(
|
|||
lp.dropped_by,
|
||||
lp.expires_at,
|
||||
lp.created_at,
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
lp.is_locked,
|
||||
lp.locked_by,
|
||||
|
|
@ -102,7 +102,7 @@ struct DropPropResult {
|
|||
/// 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).
|
||||
/// For business cards: captures dropper's profile snapshot at drop time.
|
||||
pub async fn drop_prop_to_canvas<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
inventory_item_id: Uuid,
|
||||
|
|
@ -115,9 +115,24 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
// Returns status flags plus the LooseProp data (if successful).
|
||||
// Includes scale inherited from the source prop's default_scale.
|
||||
// Transfers public state columns (server_state, realm_state, user_state).
|
||||
// For business cards: builds snapshot of dropper's profile at drop time.
|
||||
let result: Option<DropPropResult> = sqlx::query_as(
|
||||
r#"
|
||||
WITH item_info AS (
|
||||
WITH dropper_info AS (
|
||||
-- Get full profile for snapshot (business cards)
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
name_first,
|
||||
name_last,
|
||||
email,
|
||||
phone,
|
||||
summary,
|
||||
homepage
|
||||
FROM auth.users WHERE id = $2
|
||||
),
|
||||
item_info AS (
|
||||
SELECT
|
||||
inv.id,
|
||||
inv.is_droppable,
|
||||
|
|
@ -128,7 +143,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
inv.server_state,
|
||||
inv.realm_state,
|
||||
inv.user_state,
|
||||
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale
|
||||
COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale,
|
||||
(inv.server_state->>'is_owner_card')::boolean = true AS is_business_card
|
||||
FROM auth.inventory inv
|
||||
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
|
||||
|
|
@ -151,7 +167,9 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
expires_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
user_state,
|
||||
server_private_state,
|
||||
prop_name
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
|
|
@ -161,10 +179,37 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
(SELECT default_scale FROM item_info),
|
||||
$2,
|
||||
now() + interval '30 minutes',
|
||||
di.server_state,
|
||||
-- Clear is_owner_card flag for business cards when dropped
|
||||
CASE WHEN (SELECT is_business_card FROM item_info)
|
||||
THEN di.server_state - 'is_owner_card'
|
||||
ELSE di.server_state
|
||||
END,
|
||||
di.realm_state,
|
||||
di.user_state
|
||||
di.user_state,
|
||||
-- Build snapshot for business cards at drop time
|
||||
CASE WHEN (SELECT is_business_card FROM item_info) THEN
|
||||
jsonb_build_object(
|
||||
'snapshot', jsonb_build_object(
|
||||
'user_id', dr.id,
|
||||
'username', dr.username,
|
||||
'display_name', dr.display_name,
|
||||
'name_first', dr.name_first,
|
||||
'name_last', dr.name_last,
|
||||
'email', dr.email,
|
||||
'phone', dr.phone,
|
||||
'summary', dr.summary,
|
||||
'homepage', dr.homepage,
|
||||
'captured_at', now()
|
||||
)
|
||||
)
|
||||
ELSE '{}'::jsonb END,
|
||||
-- Rename business cards to "{dropper}'s Business Card"
|
||||
CASE WHEN (SELECT is_business_card FROM item_info)
|
||||
THEN dr.display_name || '''s Business Card'
|
||||
ELSE di.prop_name
|
||||
END
|
||||
FROM deleted_item di
|
||||
CROSS JOIN dropper_info dr
|
||||
RETURNING
|
||||
id,
|
||||
instance_id as channel_id,
|
||||
|
|
@ -178,7 +223,8 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
created_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state
|
||||
user_state,
|
||||
prop_name
|
||||
)
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM item_info) AS item_existed,
|
||||
|
|
@ -194,7 +240,7 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
ip.dropped_by,
|
||||
ip.expires_at,
|
||||
ip.created_at,
|
||||
di.prop_name,
|
||||
ip.prop_name,
|
||||
di.prop_asset_path,
|
||||
ip.server_state,
|
||||
ip.realm_state,
|
||||
|
|
@ -297,44 +343,364 @@ pub async fn drop_prop_to_canvas<'e>(
|
|||
}
|
||||
}
|
||||
|
||||
/// Result row type for copy_and_drop_prop query.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct CopyDropPropResult {
|
||||
item_existed: bool,
|
||||
was_droppable: bool,
|
||||
was_unique: 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>,
|
||||
}
|
||||
|
||||
/// Copy a prop from inventory and drop it to the canvas (keeping original in inventory).
|
||||
///
|
||||
/// Only works for props where `is_unique == false` and `is_droppable == true`.
|
||||
/// Creates a new loose prop with 30-minute expiry without removing from inventory.
|
||||
/// Note: Public state (server_state, realm_state, user_state) is copied to the loose prop.
|
||||
/// For business cards: captures dropper's profile snapshot at drop time.
|
||||
pub async fn copy_and_drop_prop<'e>(
|
||||
executor: impl PgExecutor<'e>,
|
||||
inventory_item_id: Uuid,
|
||||
user_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
position_x: f64,
|
||||
position_y: f64,
|
||||
) -> Result<LooseProp, AppError> {
|
||||
// Single CTE that checks existence/droppability/uniqueness and performs the operation atomically.
|
||||
// Returns status flags plus the LooseProp data (if successful).
|
||||
// Includes scale inherited from the source prop's default_scale.
|
||||
// Copies public state columns (server_state, realm_state, user_state).
|
||||
// For business cards: builds snapshot of dropper's profile at copy time.
|
||||
let result: Option<CopyDropPropResult> = sqlx::query_as(
|
||||
r#"
|
||||
WITH dropper_info AS (
|
||||
-- Get full profile for snapshot (business cards)
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
name_first,
|
||||
name_last,
|
||||
email,
|
||||
phone,
|
||||
summary,
|
||||
homepage
|
||||
FROM auth.users WHERE id = $2
|
||||
),
|
||||
item_info AS (
|
||||
SELECT
|
||||
inv.id,
|
||||
inv.is_droppable,
|
||||
inv.server_prop_id,
|
||||
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,
|
||||
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
|
||||
(inv.server_state->>'is_owner_card')::boolean = true AS is_business_card
|
||||
FROM auth.inventory inv
|
||||
LEFT JOIN server.props sp ON inv.server_prop_id = sp.id
|
||||
LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id
|
||||
WHERE inv.id = $1 AND inv.user_id = $2
|
||||
),
|
||||
inserted_prop AS (
|
||||
INSERT INTO scene.loose_props (
|
||||
instance_id,
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
position,
|
||||
scale,
|
||||
dropped_by,
|
||||
expires_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
server_private_state,
|
||||
prop_name
|
||||
)
|
||||
SELECT
|
||||
$3,
|
||||
ii.server_prop_id,
|
||||
ii.realm_prop_id,
|
||||
public.make_virtual_point($4::real, $5::real),
|
||||
ii.default_scale,
|
||||
$2,
|
||||
now() + interval '30 minutes',
|
||||
-- Clear is_owner_card flag for business cards when dropped
|
||||
CASE WHEN ii.is_business_card
|
||||
THEN ii.server_state - 'is_owner_card'
|
||||
ELSE ii.server_state
|
||||
END,
|
||||
ii.realm_state,
|
||||
ii.user_state,
|
||||
-- Build snapshot for business cards at copy time
|
||||
CASE WHEN ii.is_business_card THEN
|
||||
jsonb_build_object(
|
||||
'snapshot', jsonb_build_object(
|
||||
'user_id', dr.id,
|
||||
'username', dr.username,
|
||||
'display_name', dr.display_name,
|
||||
'name_first', dr.name_first,
|
||||
'name_last', dr.name_last,
|
||||
'email', dr.email,
|
||||
'phone', dr.phone,
|
||||
'summary', dr.summary,
|
||||
'homepage', dr.homepage,
|
||||
'captured_at', now()
|
||||
)
|
||||
)
|
||||
ELSE '{}'::jsonb END,
|
||||
-- Rename business cards to "{dropper}'s Business Card"
|
||||
CASE WHEN ii.is_business_card
|
||||
THEN dr.display_name || '''s Business Card'
|
||||
ELSE ii.prop_name
|
||||
END
|
||||
FROM item_info ii
|
||||
CROSS JOIN dropper_info dr
|
||||
WHERE ii.is_droppable = true AND ii.is_unique = false
|
||||
RETURNING
|
||||
id,
|
||||
instance_id as channel_id,
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
ST_X(position)::real as position_x,
|
||||
ST_Y(position)::real as position_y,
|
||||
scale,
|
||||
dropped_by,
|
||||
expires_at,
|
||||
created_at,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
prop_name
|
||||
)
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM item_info) AS item_existed,
|
||||
COALESCE((SELECT is_droppable FROM item_info), false) AS was_droppable,
|
||||
COALESCE((SELECT is_unique FROM item_info), true) AS was_unique,
|
||||
ip.id,
|
||||
ip.channel_id,
|
||||
ip.server_prop_id,
|
||||
ip.realm_prop_id,
|
||||
ip.position_x,
|
||||
ip.position_y,
|
||||
ip.scale,
|
||||
ip.dropped_by,
|
||||
ip.expires_at,
|
||||
ip.created_at,
|
||||
ip.prop_name,
|
||||
ii.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 item_info ii ON true
|
||||
"#,
|
||||
)
|
||||
.bind(inventory_item_id)
|
||||
.bind(user_id)
|
||||
.bind(channel_id)
|
||||
.bind(position_x as f32)
|
||||
.bind(position_y as f32)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
match result {
|
||||
None => {
|
||||
// Query returned no rows (shouldn't happen with our dummy table)
|
||||
Err(AppError::Internal(
|
||||
"Unexpected error copying prop to canvas".to_string(),
|
||||
))
|
||||
}
|
||||
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(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(r) if r.item_existed && r.was_unique => {
|
||||
// Item is unique and cannot be copied
|
||||
Err(AppError::Forbidden(
|
||||
"This prop is unique and cannot be copied".to_string(),
|
||||
))
|
||||
}
|
||||
Some(CopyDropPropResult {
|
||||
item_existed: true,
|
||||
was_droppable: true,
|
||||
was_unique: false,
|
||||
id: Some(id),
|
||||
channel_id: Some(channel_id),
|
||||
server_prop_id,
|
||||
realm_prop_id,
|
||||
position_x: Some(position_x),
|
||||
position_y: Some(position_y),
|
||||
scale: Some(scale),
|
||||
dropped_by,
|
||||
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)
|
||||
} else if let Some(rid) = realm_prop_id {
|
||||
PropSource::Realm(rid)
|
||||
} else {
|
||||
return Err(AppError::Internal(
|
||||
"Copied prop has neither server_prop_id nor realm_prop_id".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// Success! Convert f32 positions to f64.
|
||||
Ok(LooseProp {
|
||||
id,
|
||||
channel_id,
|
||||
source,
|
||||
position_x: position_x.into(),
|
||||
position_y: position_y.into(),
|
||||
scale,
|
||||
dropped_by,
|
||||
expires_at: Some(expires_at),
|
||||
created_at,
|
||||
prop_name,
|
||||
prop_asset_path,
|
||||
is_locked: false,
|
||||
locked_by: None,
|
||||
server_state,
|
||||
realm_state,
|
||||
user_state,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
// Some fields were unexpectedly null
|
||||
Err(AppError::Internal(
|
||||
"Unexpected null values in copy drop prop result".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Note: Public state is transferred from the loose prop.
|
||||
/// Private state (snapshot) is transferred from loose prop (captured at drop time).
|
||||
///
|
||||
/// For business cards:
|
||||
/// - Transfers snapshot that was captured when the card was dropped
|
||||
/// - Updates existing card from same giver instead of creating duplicate
|
||||
/// - Self-pickup: restores is_owner_card flag and clears snapshot
|
||||
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
|
||||
// Simplified pickup query:
|
||||
// - Snapshot was already captured at drop time, just transfer it
|
||||
// - Handle self-pickup (picker == dropper): restore owner state
|
||||
// - Handle deduplication for business cards from same giver
|
||||
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,
|
||||
server_state, realm_state, user_state
|
||||
RETURNING id, server_prop_id, realm_prop_id, prop_name,
|
||||
server_state, realm_state, user_state,
|
||||
server_private_state, dropped_by
|
||||
),
|
||||
source_info AS (
|
||||
SELECT
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
dp.*,
|
||||
COALESCE(dp.prop_name, sp.name, rp.name) as final_prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
COALESCE(sp.default_layer, rp.default_layer) as layer,
|
||||
COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable,
|
||||
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.server_state,
|
||||
dp.realm_state,
|
||||
dp.user_state
|
||||
COALESCE(sp.is_unique, rp.is_unique, false) as is_unique,
|
||||
-- Detect business card by presence of snapshot
|
||||
(dp.server_private_state->'snapshot') IS NOT NULL AS is_business_card,
|
||||
-- Self-pickup: picker is the original dropper
|
||||
dp.dropped_by = $2 AS is_self_pickup,
|
||||
-- Get giver's user_id from snapshot for deduplication
|
||||
(dp.server_private_state->'snapshot'->>'user_id')::uuid AS giver_id
|
||||
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
|
||||
),
|
||||
pickup_data AS (
|
||||
SELECT
|
||||
si.*,
|
||||
-- Self-pickup: restore "My Business Card" name
|
||||
CASE WHEN si.is_self_pickup AND si.is_business_card
|
||||
THEN 'My Business Card'
|
||||
ELSE si.final_prop_name
|
||||
END AS resolved_prop_name,
|
||||
-- Self-pickup: restore is_owner_card flag
|
||||
CASE WHEN si.is_self_pickup AND si.is_business_card
|
||||
THEN si.server_state || '{"is_owner_card": true}'::jsonb
|
||||
ELSE si.server_state
|
||||
END AS resolved_server_state,
|
||||
-- Self-pickup: clear snapshot (owner doesn't need it)
|
||||
CASE WHEN si.is_self_pickup AND si.is_business_card
|
||||
THEN '{}'::jsonb
|
||||
ELSE si.server_private_state
|
||||
END AS resolved_private_state
|
||||
FROM source_info si
|
||||
),
|
||||
-- Check for existing business card from same giver (not self-pickup)
|
||||
existing_card AS (
|
||||
SELECT inv.id
|
||||
FROM auth.inventory inv, pickup_data pd
|
||||
WHERE inv.user_id = $2
|
||||
AND pd.is_business_card = true
|
||||
AND NOT pd.is_self_pickup
|
||||
AND pd.giver_id IS NOT NULL
|
||||
AND (inv.server_private_state->'snapshot'->>'user_id')::uuid = pd.giver_id
|
||||
),
|
||||
-- Update existing card if found (refresh snapshot)
|
||||
updated_card AS (
|
||||
UPDATE auth.inventory inv
|
||||
SET
|
||||
server_private_state = pd.resolved_private_state,
|
||||
acquired_at = now()
|
||||
FROM pickup_data pd, existing_card ec
|
||||
WHERE inv.id = ec.id
|
||||
RETURNING inv.id, inv.server_prop_id, inv.realm_prop_id, inv.prop_name, inv.prop_asset_path, inv.layer,
|
||||
inv.is_transferable, inv.is_portable, inv.is_droppable, inv.origin, inv.acquired_at,
|
||||
inv.server_state, inv.realm_state, inv.user_state,
|
||||
inv.server_private_state, inv.realm_private_state, inv.user_private_state
|
||||
),
|
||||
-- Insert new item only if no existing card was updated
|
||||
inserted_item AS (
|
||||
INSERT INTO auth.inventory (
|
||||
user_id,
|
||||
|
|
@ -349,60 +715,63 @@ pub async fn pick_up_loose_prop<'e>(
|
|||
is_droppable,
|
||||
provenance,
|
||||
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,
|
||||
si.server_prop_id,
|
||||
si.realm_prop_id,
|
||||
si.prop_name,
|
||||
si.prop_asset_path,
|
||||
si.layer,
|
||||
pd.server_prop_id,
|
||||
pd.realm_prop_id,
|
||||
pd.resolved_prop_name,
|
||||
pd.prop_asset_path,
|
||||
pd.layer,
|
||||
'server_library'::server.prop_origin,
|
||||
COALESCE(si.is_transferable, true),
|
||||
COALESCE(si.is_portable, true),
|
||||
COALESCE(si.is_droppable, true),
|
||||
COALESCE(pd.is_transferable, true),
|
||||
COALESCE(pd.is_portable, true),
|
||||
COALESCE(pd.is_droppable, true),
|
||||
'[]'::jsonb,
|
||||
now(),
|
||||
-- Transfer public state
|
||||
si.server_state,
|
||||
si.realm_state,
|
||||
si.user_state,
|
||||
-- Private state cleared for new owner
|
||||
'{}'::jsonb,
|
||||
pd.resolved_server_state,
|
||||
pd.realm_state,
|
||||
pd.user_state,
|
||||
pd.resolved_private_state,
|
||||
'{}'::jsonb,
|
||||
'{}'::jsonb
|
||||
FROM source_info si
|
||||
FROM pickup_data pd
|
||||
WHERE NOT EXISTS (SELECT 1 FROM updated_card)
|
||||
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,
|
||||
COALESCE(ii.server_prop_id, ii.realm_prop_id) as prop_id,
|
||||
ii.prop_name,
|
||||
ii.prop_asset_path,
|
||||
ii.layer,
|
||||
ii.is_transferable,
|
||||
ii.is_portable,
|
||||
ii.is_droppable,
|
||||
ii.origin,
|
||||
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
|
||||
fr.id,
|
||||
COALESCE(fr.server_prop_id, fr.realm_prop_id) as prop_id,
|
||||
fr.prop_name,
|
||||
fr.prop_asset_path,
|
||||
fr.layer,
|
||||
fr.is_transferable,
|
||||
fr.is_portable,
|
||||
fr.is_droppable,
|
||||
pd.is_unique,
|
||||
fr.origin,
|
||||
fr.acquired_at,
|
||||
fr.server_state,
|
||||
fr.realm_state,
|
||||
fr.user_state,
|
||||
fr.server_private_state,
|
||||
fr.realm_private_state,
|
||||
fr.user_private_state
|
||||
FROM (
|
||||
SELECT * FROM updated_card
|
||||
UNION ALL
|
||||
SELECT * FROM inserted_item
|
||||
) fr
|
||||
CROSS JOIN pickup_data pd
|
||||
"#,
|
||||
)
|
||||
.bind(loose_prop_id)
|
||||
|
|
@ -465,7 +834,7 @@ pub async fn update_loose_prop_scale<'e>(
|
|||
u.dropped_by,
|
||||
u.expires_at,
|
||||
u.created_at,
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by,
|
||||
|
|
@ -504,7 +873,7 @@ pub async fn get_loose_prop_by_id<'e>(
|
|||
lp.dropped_by,
|
||||
lp.expires_at,
|
||||
lp.created_at,
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
lp.is_locked,
|
||||
lp.locked_by,
|
||||
|
|
@ -567,7 +936,7 @@ pub async fn move_loose_prop<'e>(
|
|||
u.dropped_by,
|
||||
u.expires_at,
|
||||
u.created_at,
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by,
|
||||
|
|
@ -630,7 +999,7 @@ pub async fn lock_loose_prop<'e>(
|
|||
u.dropped_by,
|
||||
u.expires_at,
|
||||
u.created_at,
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by,
|
||||
|
|
@ -691,7 +1060,7 @@ pub async fn unlock_loose_prop<'e>(
|
|||
u.dropped_by,
|
||||
u.expires_at,
|
||||
u.created_at,
|
||||
COALESCE(sp.name, rp.name) as prop_name,
|
||||
COALESCE(lp.prop_name, sp.name, rp.name) as prop_name,
|
||||
COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path,
|
||||
u.is_locked,
|
||||
u.locked_by,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{ServerConfig, UpdateServerConfigRequest};
|
||||
use crate::models::{ServerConfig, UpdateServerConfigRequest, UpdateServerDefaultAvatarsRequest};
|
||||
use chattyness_error::AppError;
|
||||
|
||||
/// The fixed UUID for the singleton server config row.
|
||||
|
|
@ -91,3 +91,34 @@ pub async fn update_server_config(
|
|||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Update server default avatars.
|
||||
pub async fn update_server_default_avatars(
|
||||
pool: &PgPool,
|
||||
req: &UpdateServerDefaultAvatarsRequest,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE server.config SET
|
||||
default_avatar_neutral_child = $1,
|
||||
default_avatar_neutral_adult = $2,
|
||||
default_avatar_male_child = $3,
|
||||
default_avatar_male_adult = $4,
|
||||
default_avatar_female_child = $5,
|
||||
default_avatar_female_adult = $6,
|
||||
updated_at = now()
|
||||
WHERE id = $7
|
||||
"#,
|
||||
)
|
||||
.bind(req.default_avatar_neutral_child)
|
||||
.bind(req.default_avatar_neutral_adult)
|
||||
.bind(req.default_avatar_male_child)
|
||||
.bind(req.default_avatar_male_adult)
|
||||
.bind(req.default_avatar_female_child)
|
||||
.bind(req.default_avatar_female_adult)
|
||||
.bind(server_config_id())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -724,3 +724,461 @@ pub fn generate_guest_name() -> String {
|
|||
let number: u32 = rng.gen_range(10000..100000);
|
||||
format!("Guest_{}", number)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Profile Queries
|
||||
// =============================================================================
|
||||
|
||||
use crate::models::{
|
||||
AvatarSource, BusinessCardSnapshot, CreateContactRequest, CreateOrganizationRequest,
|
||||
ProfileVisibility, UpdateContactRequest, UpdateOrganizationRequest,
|
||||
UpdateProfileRequest, UserContact, UserOrganization, UserProfile,
|
||||
};
|
||||
|
||||
/// Get the full user profile for editing.
|
||||
pub async fn get_user_profile(pool: &PgPool, user_id: Uuid) -> Result<Option<UserProfile>, AppError> {
|
||||
let profile = sqlx::query_as::<_, UserProfile>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
phone,
|
||||
display_name,
|
||||
name_first,
|
||||
name_last,
|
||||
summary,
|
||||
homepage,
|
||||
bio,
|
||||
avatar_source,
|
||||
profile_visibility,
|
||||
contacts_visibility,
|
||||
organizations_visibility,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM auth.users
|
||||
WHERE id = $1 AND status = 'active'
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Get a user's public profile by username.
|
||||
/// Returns the full profile data - the caller is responsible for filtering
|
||||
/// based on visibility settings and viewer authentication.
|
||||
pub async fn get_public_profile_by_username(
|
||||
pool: &PgPool,
|
||||
username: &str,
|
||||
) -> Result<Option<UserProfile>, AppError> {
|
||||
let profile = sqlx::query_as::<_, UserProfile>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
phone,
|
||||
display_name,
|
||||
name_first,
|
||||
name_last,
|
||||
summary,
|
||||
homepage,
|
||||
bio,
|
||||
avatar_source,
|
||||
profile_visibility,
|
||||
contacts_visibility,
|
||||
organizations_visibility,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM auth.users
|
||||
WHERE username = $1 AND status = 'active'
|
||||
"#,
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Update basic profile fields using a connection (for RLS support).
|
||||
pub async fn update_user_profile_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
req: &UpdateProfileRequest,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET
|
||||
display_name = $2,
|
||||
name_first = $3,
|
||||
name_last = $4,
|
||||
summary = $5,
|
||||
homepage = $6,
|
||||
bio = $7,
|
||||
phone = $8,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&req.display_name)
|
||||
.bind(&req.name_first)
|
||||
.bind(&req.name_last)
|
||||
.bind(&req.summary)
|
||||
.bind(&req.homepage)
|
||||
.bind(&req.bio)
|
||||
.bind(&req.phone)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update visibility settings using a connection (for RLS support).
|
||||
pub async fn update_visibility_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
profile_visibility: ProfileVisibility,
|
||||
contacts_visibility: ProfileVisibility,
|
||||
organizations_visibility: ProfileVisibility,
|
||||
avatar_source: AvatarSource,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.users
|
||||
SET
|
||||
profile_visibility = $2,
|
||||
contacts_visibility = $3,
|
||||
organizations_visibility = $4,
|
||||
avatar_source = $5,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(profile_visibility)
|
||||
.bind(contacts_visibility)
|
||||
.bind(organizations_visibility)
|
||||
.bind(avatar_source)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a user's profile snapshot for use in business cards.
|
||||
///
|
||||
/// Captures the essential profile fields at a point in time.
|
||||
/// Returns None if the user doesn't exist or is not active.
|
||||
pub async fn get_user_business_card_snapshot(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<BusinessCardSnapshot>, AppError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct SnapshotRow {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
display_name: String,
|
||||
name_first: Option<String>,
|
||||
name_last: Option<String>,
|
||||
email: Option<String>,
|
||||
phone: Option<String>,
|
||||
summary: Option<String>,
|
||||
homepage: Option<String>,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, SnapshotRow>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
name_first,
|
||||
name_last,
|
||||
email,
|
||||
phone,
|
||||
summary,
|
||||
homepage
|
||||
FROM auth.users
|
||||
WHERE id = $1 AND status = 'active'
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| BusinessCardSnapshot {
|
||||
user_id: r.id,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
name_first: r.name_first,
|
||||
name_last: r.name_last,
|
||||
email: r.email,
|
||||
phone: r.phone,
|
||||
summary: r.summary,
|
||||
homepage: r.homepage,
|
||||
captured_at: chrono::Utc::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Contact Queries
|
||||
// =============================================================================
|
||||
|
||||
/// List all contacts for a user.
|
||||
pub async fn list_user_contacts(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserContact>, AppError> {
|
||||
let contacts = sqlx::query_as::<_, UserContact>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
platform,
|
||||
value,
|
||||
label,
|
||||
sort_order,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM auth.user_contacts
|
||||
WHERE user_id = $1
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(contacts)
|
||||
}
|
||||
|
||||
/// Create a new contact using a connection (for RLS support).
|
||||
pub async fn create_contact_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
req: &CreateContactRequest,
|
||||
) -> Result<Uuid, AppError> {
|
||||
// Get the next sort order
|
||||
let (max_order,): (Option<i16>,) = sqlx::query_as(
|
||||
"SELECT MAX(sort_order) FROM auth.user_contacts WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let sort_order = max_order.unwrap_or(0) + 1;
|
||||
|
||||
let (contact_id,): (Uuid,) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO auth.user_contacts (user_id, platform, value, label, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(req.platform)
|
||||
.bind(&req.value)
|
||||
.bind(&req.label)
|
||||
.bind(sort_order)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(contact_id)
|
||||
}
|
||||
|
||||
/// Update an existing contact using a connection (for RLS support).
|
||||
pub async fn update_contact_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
contact_id: Uuid,
|
||||
req: &UpdateContactRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.user_contacts
|
||||
SET
|
||||
platform = $3,
|
||||
value = $4,
|
||||
label = $5,
|
||||
sort_order = $6,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(contact_id)
|
||||
.bind(user_id)
|
||||
.bind(req.platform)
|
||||
.bind(&req.value)
|
||||
.bind(&req.label)
|
||||
.bind(req.sort_order)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Contact not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a contact using a connection (for RLS support).
|
||||
pub async fn delete_contact_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
contact_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM auth.user_contacts WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.bind(user_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Contact not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Organization Queries
|
||||
// =============================================================================
|
||||
|
||||
/// List all organizations for a user.
|
||||
pub async fn list_user_organizations(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserOrganization>, AppError> {
|
||||
let organizations = sqlx::query_as::<_, UserOrganization>(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
wikidata_qid,
|
||||
name,
|
||||
role,
|
||||
department,
|
||||
start_date,
|
||||
end_date,
|
||||
is_current,
|
||||
sort_order,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM auth.user_organizations
|
||||
WHERE user_id = $1
|
||||
ORDER BY is_current DESC, sort_order ASC, start_date DESC NULLS LAST
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(organizations)
|
||||
}
|
||||
|
||||
/// Create a new organization using a connection (for RLS support).
|
||||
pub async fn create_organization_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
req: &CreateOrganizationRequest,
|
||||
) -> Result<Uuid, AppError> {
|
||||
// Get the next sort order
|
||||
let (max_order,): (Option<i16>,) = sqlx::query_as(
|
||||
"SELECT MAX(sort_order) FROM auth.user_organizations WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let sort_order = max_order.unwrap_or(0) + 1;
|
||||
|
||||
let (org_id,): (Uuid,) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO auth.user_organizations (
|
||||
user_id, wikidata_qid, name, role, department,
|
||||
start_date, end_date, is_current, sort_order
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&req.wikidata_qid)
|
||||
.bind(&req.name)
|
||||
.bind(&req.role)
|
||||
.bind(&req.department)
|
||||
.bind(req.start_date)
|
||||
.bind(req.end_date)
|
||||
.bind(req.is_current)
|
||||
.bind(sort_order)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
Ok(org_id)
|
||||
}
|
||||
|
||||
/// Update an existing organization using a connection (for RLS support).
|
||||
pub async fn update_organization_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
org_id: Uuid,
|
||||
req: &UpdateOrganizationRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.user_organizations
|
||||
SET
|
||||
wikidata_qid = $3,
|
||||
name = $4,
|
||||
role = $5,
|
||||
department = $6,
|
||||
start_date = $7,
|
||||
end_date = $8,
|
||||
is_current = $9,
|
||||
sort_order = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.bind(&req.wikidata_qid)
|
||||
.bind(&req.name)
|
||||
.bind(&req.role)
|
||||
.bind(&req.department)
|
||||
.bind(req.start_date)
|
||||
.bind(req.end_date)
|
||||
.bind(req.is_current)
|
||||
.bind(req.sort_order)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Organization not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an organization using a connection (for RLS support).
|
||||
pub async fn delete_organization_conn(
|
||||
conn: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
org_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM auth.user_organizations WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(org_id)
|
||||
.bind(user_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("Organization not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,14 @@ pub enum ClientMessage {
|
|||
inventory_item_id: Uuid,
|
||||
},
|
||||
|
||||
/// Copy and drop a prop from inventory to the canvas.
|
||||
/// Creates a copy on the scene while keeping the original in inventory.
|
||||
/// Only works for props where `is_unique == false` and `is_droppable == true`.
|
||||
CopyAndDropProp {
|
||||
/// Inventory item ID to copy and drop.
|
||||
inventory_item_id: Uuid,
|
||||
},
|
||||
|
||||
/// Pick up a loose prop from the canvas.
|
||||
PickUpProp {
|
||||
/// Loose prop ID to pick up.
|
||||
|
|
@ -347,6 +355,8 @@ pub enum ServerMessage {
|
|||
MemberIdentityUpdated {
|
||||
/// User ID of the member.
|
||||
user_id: Uuid,
|
||||
/// New username (for profile URLs).
|
||||
username: String,
|
||||
/// New display name.
|
||||
display_name: String,
|
||||
/// Whether the member is still a guest.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue