fabrica

hanna/fabrica

feat(store): user profile fields and avatar metadata

8bb3d4e · hanna committed on 2026-07-25

Add a 0002 migration (both dialects) extending users with pronouns, bio,
website, location, named social handles, and an avatar content type; NULL
avatar_mime falls back to a generated identicon and the bytes live on disk,
keeping the schema portable. Surface the fields on the model and add
update_profile / set_avatar_mime store queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +130 −2UnifiedSplit
crates/model/src/entity.rs +15 −0
@@ -65,6 +65,21 @@ pub struct User {
6565 pub email: String,
6666 /// Optional human-friendly display name.
6767 pub display_name: Option<String>,
68+ /// Optional pronouns, shown on the profile (e.g. `they/them`).
69+ pub pronouns: Option<String>,
70+ /// Optional free-text bio shown on the profile.
71+ pub bio: Option<String>,
72+ /// Optional personal website URL.
73+ pub website: Option<String>,
74+ /// Optional location string.
75+ pub location: Option<String>,
76+ /// Optional `GitHub` handle (username only, no `@`).
77+ pub social_github: Option<String>,
78+ /// Optional Mastodon handle (e.g. `@user@instance`).
79+ pub social_mastodon: Option<String>,
80+ /// Content type of the uploaded avatar, or `None` to use a generated
81+ /// identicon. The bytes live on disk, not in this row.
82+ pub avatar_mime: Option<String>,
6883 /// Argon2id PHC hash, or `None` until an invite is completed.
6984 pub password_hash: Option<String>,
7085 /// Whether the user must set a new password on next login.
crates/store/src/lib.rs +1 −1
@@ -46,7 +46,7 @@ pub use crate::keys::NewKey;
4646 pub use crate::repos::NewRepo;
4747 pub use crate::sessions::NewSession;
4848 pub use crate::tokens::NewToken;
49-pub use crate::users::NewUser;
49+pub use crate::users::{NewUser, ProfileUpdate};
5050
5151 /// Migrations for the SQLite dialect, embedded at build time.
5252 static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite");
crates/store/src/users.rs +90 −1
@@ -12,9 +12,30 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// The columns of `users` in a fixed order, shared by every `SELECT` so the
1414 /// row-mapping in [`Store::map_user`] can rely on names.
15-pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \
15+pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \
16+ website, location, social_github, social_mastodon, avatar_mime, password_hash, \
1617 must_change_password, is_admin, disabled_at, created_at, updated_at";
1718
19+/// The editable profile fields, set together by the settings page. Each `None`
20+/// clears the column.
21+#[derive(Debug, Clone, Default)]
22+pub struct ProfileUpdate {
23+ /// Display name.
24+ pub display_name: Option<String>,
25+ /// Pronouns.
26+ pub pronouns: Option<String>,
27+ /// Free-text bio.
28+ pub bio: Option<String>,
29+ /// Website URL.
30+ pub website: Option<String>,
31+ /// Location.
32+ pub location: Option<String>,
33+ /// `GitHub` handle.
34+ pub social_github: Option<String>,
35+ /// Mastodon handle.
36+ pub social_mastodon: Option<String>,
37+}
38+
1839 /// Fields needed to create a user. The server fills in the id, timestamps, and
1940 /// the normalized `*_lower` uniqueness keys.
2041 #[derive(Debug, Clone)]
@@ -52,6 +73,13 @@ impl Store {
5273 username: new.username,
5374 email: new.email,
5475 display_name: new.display_name,
76+ pronouns: None,
77+ bio: None,
78+ website: None,
79+ location: None,
80+ social_github: None,
81+ social_mastodon: None,
82+ avatar_mime: None,
5583 password_hash: new.password_hash,
5684 must_change_password: new.must_change_password,
5785 is_admin: new.is_admin,
@@ -220,6 +248,60 @@ impl Store {
220248 Ok(deleted > 0)
221249 }
222250
251+ /// Overwrite a user's profile fields (each `None` clears the column).
252+ ///
253+ /// Returns `true` if a user with `user_id` existed and was updated.
254+ ///
255+ /// # Errors
256+ ///
257+ /// Returns [`StoreError::Query`] on a database failure.
258+ pub async fn update_profile(
259+ &self,
260+ user_id: &str,
261+ profile: ProfileUpdate,
262+ ) -> Result<bool, StoreError> {
263+ let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, website = $4, \
264+ location = $5, social_github = $6, social_mastodon = $7, updated_at = $8 WHERE id = $9";
265+ let updated = sqlx::query(sql)
266+ .bind(profile.display_name)
267+ .bind(profile.pronouns)
268+ .bind(profile.bio)
269+ .bind(profile.website)
270+ .bind(profile.location)
271+ .bind(profile.social_github)
272+ .bind(profile.social_mastodon)
273+ .bind(now_ms())
274+ .bind(user_id)
275+ .execute(&self.pool)
276+ .await?
277+ .rows_affected();
278+ Ok(updated > 0)
279+ }
280+
281+ /// Set (or clear) the content type of a user's uploaded avatar. `None`
282+ /// returns the user to the generated-identicon fallback.
283+ ///
284+ /// Returns `true` if a user with `user_id` existed and was updated.
285+ ///
286+ /// # Errors
287+ ///
288+ /// Returns [`StoreError::Query`] on a database failure.
289+ pub async fn set_avatar_mime(
290+ &self,
291+ user_id: &str,
292+ mime: Option<&str>,
293+ ) -> Result<bool, StoreError> {
294+ let updated =
295+ sqlx::query("UPDATE users SET avatar_mime = $1, updated_at = $2 WHERE id = $3")
296+ .bind(mime)
297+ .bind(now_ms())
298+ .bind(user_id)
299+ .execute(&self.pool)
300+ .await?
301+ .rows_affected();
302+ Ok(updated > 0)
303+ }
304+
223305 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].
224306 pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {
225307 Ok(User {
@@ -227,6 +309,13 @@ impl Store {
227309 username: row.try_get("username")?,
228310 email: row.try_get("email")?,
229311 display_name: row.try_get("display_name")?,
312+ pronouns: row.try_get("pronouns")?,
313+ bio: row.try_get("bio")?,
314+ website: row.try_get("website")?,
315+ location: row.try_get("location")?,
316+ social_github: row.try_get("social_github")?,
317+ social_mastodon: row.try_get("social_mastodon")?,
318+ avatar_mime: row.try_get("avatar_mime")?,
230319 password_hash: row.try_get("password_hash")?,
231320 must_change_password: self.get_bool(row, "must_change_password")?,
232321 is_admin: self.get_bool(row, "is_admin")?,
migrations/postgres/0002_user_profile.sql +12 −0
@@ -0,0 +1,12 @@
1+-- Profile fields shown on the user page: pronouns, a bio, a website and
2+-- location, and named social handles. `avatar_mime` records an uploaded
3+-- avatar's content type; NULL means fall back to a generated identicon. The
4+-- avatar bytes live on disk under `{storage.data_dir}/avatars/{id}`, keeping
5+-- the schema portable (no BLOB/BYTEA divergence).
6+ALTER TABLE users ADD COLUMN pronouns TEXT;
7+ALTER TABLE users ADD COLUMN bio TEXT;
8+ALTER TABLE users ADD COLUMN website TEXT;
9+ALTER TABLE users ADD COLUMN location TEXT;
10+ALTER TABLE users ADD COLUMN social_github TEXT;
11+ALTER TABLE users ADD COLUMN social_mastodon TEXT;
12+ALTER TABLE users ADD COLUMN avatar_mime TEXT;
migrations/sqlite/0002_user_profile.sql +12 −0
@@ -0,0 +1,12 @@
1+-- Profile fields shown on the user page: pronouns, a bio, a website and
2+-- location, and named social handles. `avatar_mime` records an uploaded
3+-- avatar's content type; NULL means fall back to a generated identicon. The
4+-- avatar bytes live on disk under `{storage.data_dir}/avatars/{id}`, keeping
5+-- the schema portable (no BLOB/BYTEA divergence).
6+ALTER TABLE users ADD COLUMN pronouns TEXT;
7+ALTER TABLE users ADD COLUMN bio TEXT;
8+ALTER TABLE users ADD COLUMN website TEXT;
9+ALTER TABLE users ADD COLUMN location TEXT;
10+ALTER TABLE users ADD COLUMN social_github TEXT;
11+ALTER TABLE users ADD COLUMN social_mastodon TEXT;
12+ALTER TABLE users ADD COLUMN avatar_mime TEXT;