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 {
65 pub email: String,65 pub email: String,
66 /// Optional human-friendly display name.66 /// Optional human-friendly display name.
67 pub display_name: Option<String>,67 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>,
68 /// Argon2id PHC hash, or `None` until an invite is completed.83 /// Argon2id PHC hash, or `None` until an invite is completed.
69 pub password_hash: Option<String>,84 pub password_hash: Option<String>,
70 /// Whether the user must set a new password on next login.85 /// 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;
46pub use crate::repos::NewRepo;46pub use crate::repos::NewRepo;
47pub use crate::sessions::NewSession;47pub use crate::sessions::NewSession;
48pub use crate::tokens::NewToken;48pub use crate::tokens::NewToken;
49pub use crate::users::NewUser;49pub use crate::users::{NewUser, ProfileUpdate};
5050
51/// Migrations for the SQLite dialect, embedded at build time.51/// Migrations for the SQLite dialect, embedded at build time.
52static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite");52static 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
13/// The columns of `users` in a fixed order, shared by every `SELECT` so the13/// The columns of `users` in a fixed order, shared by every `SELECT` so the
14/// row-mapping in [`Store::map_user`] can rely on names.14/// row-mapping in [`Store::map_user`] can rely on names.
15pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \15pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \
16 website, location, social_github, social_mastodon, avatar_mime, password_hash, \
16 must_change_password, is_admin, disabled_at, created_at, updated_at";17 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)]
22pub 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
18/// Fields needed to create a user. The server fills in the id, timestamps, and39/// Fields needed to create a user. The server fills in the id, timestamps, and
19/// the normalized `*_lower` uniqueness keys.40/// the normalized `*_lower` uniqueness keys.
20#[derive(Debug, Clone)]41#[derive(Debug, Clone)]
@@ -52,6 +73,13 @@ impl Store {
52 username: new.username,73 username: new.username,
53 email: new.email,74 email: new.email,
54 display_name: new.display_name,75 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,
55 password_hash: new.password_hash,83 password_hash: new.password_hash,
56 must_change_password: new.must_change_password,84 must_change_password: new.must_change_password,
57 is_admin: new.is_admin,85 is_admin: new.is_admin,
@@ -220,6 +248,60 @@ impl Store {
220 Ok(deleted > 0)248 Ok(deleted > 0)
221 }249 }
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
223 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].305 /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`].
224 pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {306 pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> {
225 Ok(User {307 Ok(User {
@@ -227,6 +309,13 @@ impl Store {
227 username: row.try_get("username")?,309 username: row.try_get("username")?,
228 email: row.try_get("email")?,310 email: row.try_get("email")?,
229 display_name: row.try_get("display_name")?,311 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")?,
230 password_hash: row.try_get("password_hash")?,319 password_hash: row.try_get("password_hash")?,
231 must_change_password: self.get_bool(row, "must_change_password")?,320 must_change_password: self.get_bool(row, "must_change_password")?,
232 is_admin: self.get_bool(row, "is_admin")?,321 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).
6ALTER TABLE users ADD COLUMN pronouns TEXT;
7ALTER TABLE users ADD COLUMN bio TEXT;
8ALTER TABLE users ADD COLUMN website TEXT;
9ALTER TABLE users ADD COLUMN location TEXT;
10ALTER TABLE users ADD COLUMN social_github TEXT;
11ALTER TABLE users ADD COLUMN social_mastodon TEXT;
12ALTER 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).
6ALTER TABLE users ADD COLUMN pronouns TEXT;
7ALTER TABLE users ADD COLUMN bio TEXT;
8ALTER TABLE users ADD COLUMN website TEXT;
9ALTER TABLE users ADD COLUMN location TEXT;
10ALTER TABLE users ADD COLUMN social_github TEXT;
11ALTER TABLE users ADD COLUMN social_mastodon TEXT;
12ALTER TABLE users ADD COLUMN avatar_mime TEXT;