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:
Evan Carroll 2026-01-25 10:50:10 -06:00
parent cd8dfb94a3
commit 710985638f
35 changed files with 4932 additions and 435 deletions

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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,

View file

@ -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(())
}

View file

@ -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(())
}

View file

@ -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.