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,
|
"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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ type Post struct {
|
|||||||
// is a reshare; the four `Original*` fields carry the canonical
|
// is a reshare; the four `Original*` fields carry the canonical
|
||||||
// post's author info so the frontend can render a
|
// post's author info so the frontend can render a
|
||||||
// "Reshared from @handle" link without an extra round-trip.
|
// "Reshared from @handle" link without an extra round-trip.
|
||||||
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
ResharedFromPostID *string `json:"reshared_from_post_id,omitempty"`
|
||||||
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
OriginalAuthorName *string `json:"original_author_name,omitempty"`
|
||||||
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
OriginalAuthorHandle *string `json:"original_author_handle,omitempty"`
|
||||||
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
OriginalAuthorAvatar *string `json:"original_author_avatar_url,omitempty"`
|
||||||
|
|
||||||
// ViewerHasReshared is true when the calling user has reshared the
|
// ViewerHasReshared is true when the calling user has reshared the
|
||||||
// canonical version of this post. Hydrated by the service layer
|
// 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
|
// PublicProfile is what /u/{handle} returns — the parts of a User safe to
|
||||||
// expose to anyone, plus aggregate counts and the user's posts.
|
// expose to anyone, plus aggregate counts and the user's posts.
|
||||||
type PublicProfile struct {
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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