Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions crates/nvisy-postgres/src/model/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Optional URL to profile avatar image.
pub avatar_url: Option<String>,
/// Timezone identifier (e.g., "America/New_York", "UTC").
Expand Down Expand Up @@ -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<String>,
/// Optional URL to profile avatar image.
pub avatar_url: Option<String>,
/// Timezone identifier.
Expand All @@ -93,8 +89,6 @@ pub struct UpdateAccount {
pub email_address: Option<String>,
/// Securely hashed password.
pub password_hash: Option<String>,
/// Company affiliation for business accounts.
pub company_name: Option<Option<String>>,
/// URL to profile avatar image.
pub avatar_url: Option<String>,
/// Timezone identifier.
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 0 additions & 7 deletions crates/nvisy-postgres/src/query/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion crates/nvisy-postgres/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ diesel::table! {
display_name -> Nullable<Text>,
email_address -> Text,
password_hash -> Text,
company_name -> Nullable<Text>,
avatar_url -> Nullable<Text>,
timezone -> Text,
locale -> Text,
Expand Down
3 changes: 0 additions & 3 deletions crates/nvisy-postgres/src/types/constraint/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -68,7 +66,6 @@ impl AccountConstraints {
| AccountConstraints::EmailLengthMax
| AccountConstraints::PasswordHashNotEmpty
| AccountConstraints::PasswordHashLengthMin
| AccountConstraints::CompanyNameLengthMax
| AccountConstraints::TimezoneFormat
| AccountConstraints::LocaleFormat
| AccountConstraints::SuspendedNotAdmin => ConstraintCategory::Validation,
Expand Down
4 changes: 2 additions & 2 deletions crates/nvisy-postgres/src/types/username.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}).",
})
}
}
Expand Down
22 changes: 14 additions & 8 deletions crates/nvisy-server/src/handler/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -49,9 +49,12 @@ fn get_own_account_docs(op: TransformOperation) -> TransformOperation {
.response::<401, Json<ErrorResponse>>()
}

/// 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(
Expand All @@ -63,7 +66,7 @@ async fn get_account(
State(pg_client): State<PgClient>,
AuthState(auth_claims): AuthState,
Path(path_params): Path<AccountPathParams>,
) -> Result<(StatusCode, Json<Account>)> {
) -> Result<(StatusCode, Json<PublicAccount>)> {
tracing::debug!(target: TRACING_TARGET, "Reading account by username");

let mut conn = pg_client.get_connection().await?;
Expand All @@ -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<Account>>()
.response::<200, Json<PublicAccount>>()
.response::<401, Json<ErrorResponse>>()
.response::<403, Json<ErrorResponse>>()
.response::<404, Json<ErrorResponse>>()
Expand Down Expand Up @@ -247,6 +250,9 @@ pub fn routes(_state: ServiceState) -> ApiRouter<ServiceState> {
.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"))
}
1 change: 0 additions & 1 deletion crates/nvisy-server/src/handler/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 0 additions & 3 deletions crates/nvisy-server/src/handler/error/pg_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ impl From<AccountConstraints> 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")
}
Expand Down
4 changes: 0 additions & 4 deletions crates/nvisy-server/src/handler/request/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ pub struct UpdateAccount {
/// New password (will be hashed before storage).
#[validate(length(min = 8, max = 256))]
pub password: Option<String>,
/// Company or organization name (empty string clears the value).
#[validate(length(max = 100))]
pub company_name: Option<String>,
}

impl UpdateAccount {
Expand All @@ -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()
}
}
Expand Down
31 changes: 27 additions & 4 deletions crates/nvisy-server/src/handler/response/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ pub struct Account {
pub display_name: Option<String>,
/// Email address associated with the account.
pub email_address: String,
/// Company name (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub company_name: Option<String>,

/// Timestamp when the account was created.
pub created_at: Timestamp,
Expand All @@ -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<String>,
/// 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(),
}
}
}
6 changes: 1 addition & 5 deletions migrations/2025-05-21-121131_accounts/up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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',
Expand Down Expand Up @@ -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';
Expand Down