diff --git a/crates/nvisy-postgres/src/model/account.rs b/crates/nvisy-postgres/src/model/account.rs index c95ad5b..09b6392 100644 --- a/crates/nvisy-postgres/src/model/account.rs +++ b/crates/nvisy-postgres/src/model/account.rs @@ -39,8 +39,6 @@ pub struct Account { pub email_address: String, /// Securely hashed password (bcrypt recommended, minimum 60 characters). pub password_hash: String, - /// Optional company affiliation for business accounts. - pub company_name: Option, /// Optional URL to profile avatar image. pub avatar_url: Option, /// Timezone identifier (e.g., "America/New_York", "UTC"). @@ -70,8 +68,6 @@ pub struct NewAccount { pub email_address: String, /// Securely hashed password (bcrypt recommended, minimum 60 characters). pub password_hash: String, - /// Optional company affiliation for business accounts. - pub company_name: Option, /// Optional URL to profile avatar image. pub avatar_url: Option, /// Timezone identifier. @@ -93,8 +89,6 @@ pub struct UpdateAccount { pub email_address: Option, /// Securely hashed password. pub password_hash: Option, - /// Company affiliation for business accounts. - pub company_name: Option>, /// URL to profile avatar image. pub avatar_url: Option, /// Timezone identifier. @@ -142,13 +136,6 @@ impl Account { self.is_active() && self.is_admin() } - /// Returns whether the account has a company name set. - pub fn has_company(&self) -> bool { - self.company_name - .as_deref() - .is_some_and(|company_name| !company_name.is_empty()) - } - /// Returns whether the account has an avatar URL configured. pub fn has_avatar(&self) -> bool { self.avatar_url.is_some() diff --git a/crates/nvisy-postgres/src/query/account.rs b/crates/nvisy-postgres/src/query/account.rs index 98611df..e2c4e94 100644 --- a/crates/nvisy-postgres/src/query/account.rs +++ b/crates/nvisy-postgres/src/query/account.rs @@ -146,9 +146,6 @@ impl AccountRepository for PgConnection { *name = name.trim().to_owned(); } new_account.email_address = new_account.email_address.trim().to_lowercase(); - if let Some(ref mut company) = new_account.company_name { - *company = company.trim().to_owned(); - } diesel::insert_into(accounts::table) .values(&new_account) @@ -227,10 +224,6 @@ impl AccountRepository for PgConnection { if let Some(email) = updates.email_address.as_mut() { *email = email.trim().to_lowercase(); } - // Some(None) clears, Some(Some(value)) sets, None skips - updates.company_name = updates - .company_name - .map(|opt| opt.map(|c| c.trim().to_owned()).filter(|c| !c.is_empty())); diesel::update(accounts::table.filter(dsl::id.eq(account_id))) .set(&updates) diff --git a/crates/nvisy-postgres/src/schema.rs b/crates/nvisy-postgres/src/schema.rs index 882421f..aaf5161 100644 --- a/crates/nvisy-postgres/src/schema.rs +++ b/crates/nvisy-postgres/src/schema.rs @@ -109,7 +109,6 @@ diesel::table! { display_name -> Nullable, email_address -> Text, password_hash -> Text, - company_name -> Nullable, avatar_url -> Nullable, timezone -> Text, locale -> Text, diff --git a/crates/nvisy-postgres/src/types/constraint/accounts.rs b/crates/nvisy-postgres/src/types/constraint/accounts.rs index b36eb40..c1e54de 100644 --- a/crates/nvisy-postgres/src/types/constraint/accounts.rs +++ b/crates/nvisy-postgres/src/types/constraint/accounts.rs @@ -27,8 +27,6 @@ pub enum AccountConstraints { PasswordHashNotEmpty, #[strum(serialize = "accounts_password_hash_length_min")] PasswordHashLengthMin, - #[strum(serialize = "accounts_company_name_length_max")] - CompanyNameLengthMax, #[strum(serialize = "accounts_timezone_format")] TimezoneFormat, #[strum(serialize = "accounts_locale_format")] @@ -68,7 +66,6 @@ impl AccountConstraints { | AccountConstraints::EmailLengthMax | AccountConstraints::PasswordHashNotEmpty | AccountConstraints::PasswordHashLengthMin - | AccountConstraints::CompanyNameLengthMax | AccountConstraints::TimezoneFormat | AccountConstraints::LocaleFormat | AccountConstraints::SuspendedNotAdmin => ConstraintCategory::Validation, diff --git a/crates/nvisy-postgres/src/types/username.rs b/crates/nvisy-postgres/src/types/username.rs index 0ac1e2a..bf417de 100644 --- a/crates/nvisy-postgres/src/types/username.rs +++ b/crates/nvisy-postgres/src/types/username.rs @@ -32,7 +32,7 @@ pub enum UsernameError { /// A validated, public account handle. /// /// The username is the human-facing identity of an account: it addresses the -/// public profile at `/u/{username}` and appears in place of the account's +/// public profile at `/accounts/{username}` and appears in place of the account's /// database id everywhere the API refers to an account. Unlike a /// [`Slug`](crate::types::Slug), a username is globally unique and may be /// changed by its owner. The invariants — `[a-z0-9]` with single internal @@ -145,7 +145,7 @@ impl schemars::JsonSchema for Username { "pattern": r"^[a-z0-9]+(?:-[a-z0-9]+)*$", "minLength": USERNAME_MIN_LENGTH, "maxLength": USERNAME_MAX_LENGTH, - "description": "Public account handle used in URLs (e.g. /u/{username}).", + "description": "Public account handle used in URLs (e.g. /accounts/{username}).", }) } } diff --git a/crates/nvisy-server/src/handler/accounts.rs b/crates/nvisy-server/src/handler/accounts.rs index 149fce2..aaa865f 100644 --- a/crates/nvisy-server/src/handler/accounts.rs +++ b/crates/nvisy-server/src/handler/accounts.rs @@ -15,7 +15,7 @@ use nvisy_postgres::{PgClient, PgConn}; use uuid::Uuid; use super::request::{AccountPathParams, UpdateAccount}; -use super::response::{Account, ErrorResponse}; +use super::response::{Account, ErrorResponse, PublicAccount}; use crate::extract::{AuthState, Json, Path, ValidateJson}; use crate::handler::{Error, ErrorKind, Result}; use crate::service::{PasswordService, ServiceState}; @@ -49,9 +49,12 @@ fn get_own_account_docs(op: TransformOperation) -> TransformOperation { .response::<401, Json>() } -/// Retrieves an account by its public handle. +/// Retrieves the public profile of an account by its handle. /// -/// The requester must share at least one workspace with the target account. +/// The requester must share at least one workspace with the target account; +/// otherwise the account is reported as not found. Only public fields are +/// returned — private details (email) are available solely through the +/// caller's own `/account/` view. #[tracing::instrument( skip_all, fields( @@ -63,7 +66,7 @@ async fn get_account( State(pg_client): State, AuthState(auth_claims): AuthState, Path(path_params): Path, -) -> Result<(StatusCode, Json)> { +) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading account by username"); let mut conn = pg_client.get_connection().await?; @@ -90,16 +93,16 @@ async fn get_account( tracing::info!(target: TRACING_TARGET, "Account read by username"); - Ok((StatusCode::OK, Json(Account::from_model(account)))) + Ok((StatusCode::OK, Json(PublicAccount::from_model(account)))) } fn get_account_docs(op: TransformOperation) -> TransformOperation { op.summary("Get account by username") .description( - "Returns an account's details by its public handle. \ + "Returns an account's public profile by its handle. \ The requester must share at least one workspace with the target account.", ) - .response::<200, Json>() + .response::<200, Json>() .response::<401, Json>() .response::<403, Json>() .response::<404, Json>() @@ -247,6 +250,9 @@ pub fn routes(_state: ServiceState) -> ApiRouter { .patch_with(update_own_account, update_own_account_docs) .delete_with(delete_own_account, delete_own_account_docs), ) - .api_route("/u/{username}/", get_with(get_account, get_account_docs)) + .api_route( + "/accounts/{username}/", + get_with(get_account, get_account_docs), + ) .with_path_items(|item| item.tag("Accounts")) } diff --git a/crates/nvisy-server/src/handler/authentication.rs b/crates/nvisy-server/src/handler/authentication.rs index 505ff45..30e3068 100644 --- a/crates/nvisy-server/src/handler/authentication.rs +++ b/crates/nvisy-server/src/handler/authentication.rs @@ -185,7 +185,6 @@ async fn signup( display_name: request.display_name, email_address: request.email_address, password_hash, - company_name: None, avatar_url: None, timezone: None, locale: None, diff --git a/crates/nvisy-server/src/handler/error/pg_account.rs b/crates/nvisy-server/src/handler/error/pg_account.rs index c9aaaac..3514466 100644 --- a/crates/nvisy-server/src/handler/error/pg_account.rs +++ b/crates/nvisy-server/src/handler/error/pg_account.rs @@ -34,9 +34,6 @@ impl From for Error<'static> { AccountConstraints::PasswordHashLengthMin => { ErrorKind::BadRequest.with_message("Password hash is too short") } - AccountConstraints::CompanyNameLengthMax => { - ErrorKind::BadRequest.with_message("Company name is too long") - } AccountConstraints::TimezoneFormat => { ErrorKind::BadRequest.with_message("Invalid timezone format") } diff --git a/crates/nvisy-server/src/handler/request/accounts.rs b/crates/nvisy-server/src/handler/request/accounts.rs index 6707c9b..72afb9a 100644 --- a/crates/nvisy-server/src/handler/request/accounts.rs +++ b/crates/nvisy-server/src/handler/request/accounts.rs @@ -24,9 +24,6 @@ pub struct UpdateAccount { /// New password (will be hashed before storage). #[validate(length(min = 8, max = 256))] pub password: Option, - /// Company or organization name (empty string clears the value). - #[validate(length(max = 100))] - pub company_name: Option, } impl UpdateAccount { @@ -39,7 +36,6 @@ impl UpdateAccount { display_name: self.display_name.map(Some), email_address: self.email_address, password_hash, - company_name: self.company_name.map(Some), ..Default::default() } } diff --git a/crates/nvisy-server/src/handler/response/accounts.rs b/crates/nvisy-server/src/handler/response/accounts.rs index 24f550b..69fda8a 100644 --- a/crates/nvisy-server/src/handler/response/accounts.rs +++ b/crates/nvisy-server/src/handler/response/accounts.rs @@ -25,9 +25,6 @@ pub struct Account { pub display_name: Option, /// Email address associated with the account. pub email_address: String, - /// Company name (optional). - #[serde(skip_serializing_if = "Option::is_none")] - pub company_name: Option, /// Timestamp when the account was created. pub created_at: Timestamp, @@ -45,10 +42,36 @@ impl Account { display_name: account.display_name, email_address: account.email_address, - company_name: account.company_name, created_at: account.created_at.into(), updated_at: account.updated_at.into(), } } } + +/// Public view of an account, returned when looking up someone other than the +/// authenticated caller. Carries only the fields safe to share with a +/// workspace peer; private details (email, account flags) are omitted +/// and remain available solely through the caller's own `/account/` view. +#[must_use] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PublicAccount { + /// Public handle of the account. + pub username: Username, + /// Display name of the account holder, when set. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Timestamp when the account was created. + pub created_at: Timestamp, +} + +impl PublicAccount { + pub fn from_model(account: model::Account) -> Self { + Self { + username: account.username, + display_name: account.display_name, + created_at: account.created_at.into(), + } + } +} diff --git a/migrations/2025-05-21-121131_accounts/up.sql b/migrations/2025-05-21-121131_accounts/up.sql index 6506fa3..e2882d8 100644 --- a/migrations/2025-05-21-121131_accounts/up.sql +++ b/migrations/2025-05-21-121131_accounts/up.sql @@ -22,7 +22,7 @@ CREATE TABLE accounts ( -- Public account handle, unique across all accounts: lowercase alphanumeric -- with single internal dashes, 3-32 characters. Addresses the profile at - -- /u/{username} and stands in for the account id at the API boundary. + -- /accounts/{username} and stands in for the account id at the API boundary. username TEXT NOT NULL, CONSTRAINT accounts_username_length CHECK (length(username) BETWEEN 3 AND 32), @@ -41,11 +41,8 @@ CREATE TABLE accounts ( CONSTRAINT accounts_password_hash_length_min CHECK (length(password_hash) >= 60), -- Optional profile information - company_name TEXT DEFAULT NULL, avatar_url TEXT DEFAULT NULL, - CONSTRAINT accounts_company_name_length_max CHECK (company_name IS NULL OR length(company_name) <= 255), - -- Preferences and settings timezone TEXT NOT NULL DEFAULT 'UTC', locale TEXT NOT NULL DEFAULT 'en-US', @@ -106,7 +103,6 @@ COMMENT ON COLUMN accounts.is_suspended IS 'Temporarily disables account access COMMENT ON COLUMN accounts.display_name IS 'Optional human-readable name for UI and communications (2-32 characters)'; COMMENT ON COLUMN accounts.email_address IS 'Primary email for authentication and communications (validated format)'; COMMENT ON COLUMN accounts.password_hash IS 'Securely hashed password (bcrypt recommended, minimum 60 characters)'; -COMMENT ON COLUMN accounts.company_name IS 'Optional company affiliation for business accounts'; COMMENT ON COLUMN accounts.avatar_url IS 'URL to user profile image or avatar'; COMMENT ON COLUMN accounts.timezone IS 'User timezone for date/time display preferences'; COMMENT ON COLUMN accounts.locale IS 'User locale for language and regional formatting';