From 623935df22cc6bc34c26a4b4370eb43e6a78b567 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Fri, 7 Aug 2026 23:47:03 +0530 Subject: [PATCH] feat: profile bio --- internal/handler/social.go | 1 + internal/handler/user.go | 2 ++ internal/model/social.go | 16 +++++++++------- internal/model/user.go | 7 +++++++ internal/repository/user_repo.go | 6 ++++-- internal/service/user_service.go | 17 +++++++++++++++++ migrations/0047_add_users_bio.down.sql | 2 ++ migrations/0047_add_users_bio.up.sql | 13 +++++++++++++ 8 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 migrations/0047_add_users_bio.down.sql create mode 100644 migrations/0047_add_users_bio.up.sql diff --git a/internal/handler/social.go b/internal/handler/social.go index 181042b..a5b3a78 100644 --- a/internal/handler/social.go +++ b/internal/handler/social.go @@ -678,6 +678,7 @@ func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { "handle": u.Handle, "name": u.Name, "avatar_url": u.AvatarURL, + "bio": u.Bio, "joined_at": u.CreatedAt, "country": u.Country, "post_count": count, diff --git a/internal/handler/user.go b/internal/handler/user.go index dd1c8b9..ca0242f 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -30,6 +30,7 @@ type updateProfileReq struct { Name *string `json:"name"` AvatarURL *string `json:"avatar_url"` Handle *string `json:"handle"` + Bio *string `json:"bio"` Country *string `json:"country"` // Devotional profile (user-editable). Sampradaya *string `json:"sampradaya"` @@ -57,6 +58,7 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { Name: req.Name, AvatarURL: req.AvatarURL, Handle: req.Handle, + Bio: req.Bio, Country: req.Country, Sampradaya: req.Sampradaya, Gender: req.Gender, diff --git a/internal/model/social.go b/internal/model/social.go index e2a43d4..e223cf8 100644 --- a/internal/model/social.go +++ b/internal/model/social.go @@ -34,10 +34,10 @@ type Post struct { // is a reshare; the four `Original*` fields carry the canonical // post's author info so the frontend can render a // "Reshared from @handle" link without an extra round-trip. - ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"` - OriginalAuthorName *string `json:"original_author_name,omitempty"` - OriginalAuthorHandle *string `json:"original_author_handle,omitempty"` - OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"` + ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"` + OriginalAuthorName *string `json:"original_author_name,omitempty"` + OriginalAuthorHandle *string `json:"original_author_handle,omitempty"` + OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"` // ViewerHasReshared is true when the calling user has reshared the // canonical version of this post. Hydrated by the service layer @@ -146,9 +146,11 @@ type CollectionRecord struct { // PublicProfile is what /u/{handle} returns — the parts of a User safe to // expose to anyone, plus aggregate counts and the user's posts. type PublicProfile struct { - Handle string `json:"handle"` - Name string `json:"name"` - AvatarURL *string `json:"avatar_url"` + Handle string `json:"handle"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` + // Bio is plain text; `@handle` mentions are linkified at render time. + Bio *string `json:"bio"` JoinedAt time.Time `json:"joined_at"` PostCount int `json:"post_count"` BookmarkCount int `json:"-"` // never serialized — private diff --git a/internal/model/user.go b/internal/model/user.go index b3ddafc..db932bb 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -52,6 +52,13 @@ type User struct { // public profile exposes only the derived `is_direct_disciple` flag. DiscipleID *string `json:"-"` + // Bio is a short self-introduction (max 200 characters, enforced in the + // service by rune count) shown on the profile header. Stored as PLAIN + // TEXT — `@handle` mentions are kept verbatim and linkified by the + // frontend at render time, so it must never be injected as HTML. + // nil when unset. + Bio *string `json:"bio"` + // --- Devotional profile (user-editable) --- // Gender is a free-but-constrained string ("male"|"female"|"other"), // nil when unspecified. diff --git a/internal/repository/user_repo.go b/internal/repository/user_repo.go index bdcf6ed..d488d9a 100644 --- a/internal/repository/user_repo.go +++ b/internal/repository/user_repo.go @@ -38,7 +38,7 @@ const userColumns = `id, email, handle, password_hash, name, avatar_url, role, p sampradaya, gender, initiated, initiated_at, daily_chanting_rounds, preferred_chanting_time, peak_active_time, banned_at, bookmark_limit, public_collection_limit, private_collection_limit, - disciple_id` + disciple_id, bio` // rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so // scanUser works for single-row and iterated reads alike. @@ -54,7 +54,7 @@ func scanUser(row rowScanner, u *model.User) error { &u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds, &u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt, &u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit, - &u.DiscipleID, + &u.DiscipleID, &u.Bio, ) } @@ -66,6 +66,7 @@ type ProfileUpdate struct { Name *string AvatarURL *string Handle *string + Bio *string // nil=leave, ""=clear, text=set Country *string Sampradaya *string // nil=leave, ""=clear, code=set Gender *string @@ -232,6 +233,7 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate set("handle", *p.Handle) } nullableString("avatar_url", p.AvatarURL) + nullableString("bio", p.Bio) nullableString("country", p.Country) nullableString("sampradaya", p.Sampradaya) nullableString("gender", p.Gender) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index eacb01e..76e920d 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -9,6 +9,7 @@ import ( "path" "strings" "time" + "unicode/utf8" "github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/repository" @@ -43,6 +44,7 @@ type UpdateProfileInput struct { Name *string AvatarURL *string Handle *string + Bio *string Country *string Sampradaya *string Gender *string @@ -53,6 +55,10 @@ type UpdateProfileInput struct { PeakActiveTime *string } +// MaxBioRunes caps the profile bio. Counted in runes so the limit means the +// same thing in every script. Keep in sync with the editor's counter. +const MaxBioRunes = 200 + // validGenders is the accepted set; "" clears the field. var validGenders = map[string]bool{"male": true, "female": true, "other": true} @@ -87,6 +93,17 @@ func (s *UserService) UpdateProfile(ctx context.Context, id string, in UpdatePro } p.Handle = &clean } + if in.Bio != nil { + // "" clears. Counted in runes, not bytes, so a devotee writing in + // Devanāgarī gets the same 200 characters as a Latin-script writer + // (len() would cut them off after ~66). Collapse the CRLF that + // Windows browsers submit so the count matches what the editor shows. + b := strings.TrimSpace(strings.ReplaceAll(*in.Bio, "\r\n", "\n")) + if utf8.RuneCountInString(b) > MaxBioRunes { + return nil, fmt.Errorf("bio too long (max %d characters)", MaxBioRunes) + } + p.Bio = &b + } if in.Country != nil { // "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the // frontend dropdown only ever sends valid codes). diff --git a/migrations/0047_add_users_bio.down.sql b/migrations/0047_add_users_bio.down.sql new file mode 100644 index 0000000..1e1aff7 --- /dev/null +++ b/migrations/0047_add_users_bio.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS bio; diff --git a/migrations/0047_add_users_bio.up.sql b/migrations/0047_add_users_bio.up.sql new file mode 100644 index 0000000..2712598 --- /dev/null +++ b/migrations/0047_add_users_bio.up.sql @@ -0,0 +1,13 @@ +-- Profile bio — a short self-introduction shown on the profile header. +-- +-- Plain text (never HTML): `@handle` mentions are stored verbatim and linkified +-- at render time by the frontend, so nothing user-supplied is ever trusted as +-- markup. NULL means "no bio set", which is distinct from an empty string — +-- the update path clears to NULL rather than storing ''. +-- +-- The 200-character cap is enforced in the service layer (by rune count, so a +-- devotee writing in Devanāgarī gets the same 200 characters a Latin-script +-- writer does). No CHECK constraint here: the column stays permissive so a +-- future cap change doesn't need a migration on a large table. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS bio TEXT;