feat: profile bio

This commit is contained in:
Krispire Admin
2026-08-07 23:47:03 +05:30
parent afcdd7ef19
commit 623935df22
8 changed files with 55 additions and 9 deletions
+1
View File
@@ -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,
+2
View File
@@ -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,
+9 -7
View File
@@ -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
+7
View File
@@ -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.
+4 -2
View File
@@ -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)
+17
View File
@@ -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).
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN IF EXISTS bio;
+13
View File
@@ -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;