feat: profile bio
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS bio;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user