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, "handle": u.Handle,
"name": u.Name, "name": u.Name,
"avatar_url": u.AvatarURL, "avatar_url": u.AvatarURL,
"bio": u.Bio,
"joined_at": u.CreatedAt, "joined_at": u.CreatedAt,
"country": u.Country, "country": u.Country,
"post_count": count, "post_count": count,
+2
View File
@@ -30,6 +30,7 @@ type updateProfileReq struct {
Name *string `json:"name"` Name *string `json:"name"`
AvatarURL *string `json:"avatar_url"` AvatarURL *string `json:"avatar_url"`
Handle *string `json:"handle"` Handle *string `json:"handle"`
Bio *string `json:"bio"`
Country *string `json:"country"` Country *string `json:"country"`
// Devotional profile (user-editable). // Devotional profile (user-editable).
Sampradaya *string `json:"sampradaya"` Sampradaya *string `json:"sampradaya"`
@@ -57,6 +58,7 @@ func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
Name: req.Name, Name: req.Name,
AvatarURL: req.AvatarURL, AvatarURL: req.AvatarURL,
Handle: req.Handle, Handle: req.Handle,
Bio: req.Bio,
Country: req.Country, Country: req.Country,
Sampradaya: req.Sampradaya, Sampradaya: req.Sampradaya,
Gender: req.Gender, Gender: req.Gender,
+2
View File
@@ -149,6 +149,8 @@ type PublicProfile struct {
Handle string `json:"handle"` Handle string `json:"handle"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL *string `json:"avatar_url"` 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"` JoinedAt time.Time `json:"joined_at"`
PostCount int `json:"post_count"` PostCount int `json:"post_count"`
BookmarkCount int `json:"-"` // never serialized — private 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. // public profile exposes only the derived `is_direct_disciple` flag.
DiscipleID *string `json:"-"` 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) --- // --- Devotional profile (user-editable) ---
// Gender is a free-but-constrained string ("male"|"female"|"other"), // Gender is a free-but-constrained string ("male"|"female"|"other"),
// nil when unspecified. // 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, sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time, banned_at, preferred_chanting_time, peak_active_time, banned_at,
bookmark_limit, public_collection_limit, private_collection_limit, 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 // rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike. // 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.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt, &u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit, &u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
&u.DiscipleID, &u.DiscipleID, &u.Bio,
) )
} }
@@ -66,6 +66,7 @@ type ProfileUpdate struct {
Name *string Name *string
AvatarURL *string AvatarURL *string
Handle *string Handle *string
Bio *string // nil=leave, ""=clear, text=set
Country *string Country *string
Sampradaya *string // nil=leave, ""=clear, code=set Sampradaya *string // nil=leave, ""=clear, code=set
Gender *string Gender *string
@@ -232,6 +233,7 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate
set("handle", *p.Handle) set("handle", *p.Handle)
} }
nullableString("avatar_url", p.AvatarURL) nullableString("avatar_url", p.AvatarURL)
nullableString("bio", p.Bio)
nullableString("country", p.Country) nullableString("country", p.Country)
nullableString("sampradaya", p.Sampradaya) nullableString("sampradaya", p.Sampradaya)
nullableString("gender", p.Gender) nullableString("gender", p.Gender)
+17
View File
@@ -9,6 +9,7 @@ import (
"path" "path"
"strings" "strings"
"time" "time"
"unicode/utf8"
"github.com/prabhupadasays/spsays-bff/internal/model" "github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository" "github.com/prabhupadasays/spsays-bff/internal/repository"
@@ -43,6 +44,7 @@ type UpdateProfileInput struct {
Name *string Name *string
AvatarURL *string AvatarURL *string
Handle *string Handle *string
Bio *string
Country *string Country *string
Sampradaya *string Sampradaya *string
Gender *string Gender *string
@@ -53,6 +55,10 @@ type UpdateProfileInput struct {
PeakActiveTime *string 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. // validGenders is the accepted set; "" clears the field.
var validGenders = map[string]bool{"male": true, "female": true, "other": true} 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 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 { if in.Country != nil {
// "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the // "" clears; otherwise a 2-letter ISO-3166-1 alpha-2 code (the
// frontend dropdown only ever sends valid codes). // 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;