feat: social graph features

This commit is contained in:
Krispire Admin
2026-06-04 19:29:48 +05:30
parent d9678ebb3b
commit efa1eaf23b
28 changed files with 1882 additions and 78 deletions
+141 -55
View File
@@ -29,21 +29,62 @@ func NewUserRepo(pg *Postgres) *UserRepo {
return &UserRepo{pg: pg}
}
// userColumns is the canonical column list for SELECT/RETURNING on the
// users table, in the exact order scanUser reads them. Keeping the two in
// lockstep here is what makes adding a column a single-edit change instead
// of a hunt across every query.
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at, country,
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
preferred_chanting_time, peak_active_time`
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
// scanUser works for single-row and iterated reads alike.
type rowScanner interface {
Scan(dest ...any) error
}
// scanUser reads one users row (selected via userColumns) into u.
func scanUser(row rowScanner, u *model.User) error {
return row.Scan(
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
&u.PreferredChantingTime, &u.PeakActiveTime,
)
}
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
type ProfileUpdate struct {
Name *string
AvatarURL *string
Handle *string
Country *string
Sampradaya *string // nil=leave, ""=clear, code=set
Gender *string
Initiated *bool
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
DailyChantingRounds *int // 0 to clear
PreferredChantingTime *string
PeakActiveTime *string
}
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
// unique-violation. The caller is responsible for picking a unique handle —
// the auth service does this with a retry loop.
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
const q = `
q := `
INSERT INTO users (email, password_hash, name, handle)
VALUES ($1, $2, $3, $4)
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
RETURNING ` + userColumns
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q,
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
), u)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
@@ -68,11 +109,10 @@ func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User,
// up as users with email=NULL until they fill it in via profile settings.
// Same handle-collision retry contract as Create.
func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
const q = `
q := `
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
RETURNING ` + userColumns
var normEmail *string
if email != nil {
v := strings.ToLower(strings.TrimSpace(*email))
@@ -81,10 +121,9 @@ func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle
}
}
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q,
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
normEmail, name, handle, avatarURL, emailVerified,
).Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
), u)
if err != nil {
if isUniqueViolation(err, "users_handle_key") {
return nil, ErrHandleTaken
@@ -109,15 +148,10 @@ func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User
}
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
q := `
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at
FROM users ` + where + ` LIMIT 1`
q := `SELECT ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, args...).
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
@@ -135,19 +169,16 @@ func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*mod
// without one. The caller (auth service) is expected to follow up by
// minting and sending a verification token.
func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) {
const q = `
q := `
UPDATE users
SET email = $2,
email_verified_at = NULL,
updated_at = now()
WHERE id = $1
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
RETURNING ` + userColumns
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))).
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
@@ -160,35 +191,77 @@ func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User,
return u, nil
}
// UpdateProfile updates the user's name, avatar, and/or handle. Pass nil to
// leave a field unchanged. Returns the updated user.
// UpdateProfile applies a partial profile patch. Only the non-nil fields of
// `p` are touched; the SQL is built dynamically so an untouched column is
// never written (keeps updated_at honest and the patch minimal). For the
// nullable string fields a non-nil "" clears the column to NULL.
//
// `handle` collisions surface as ErrHandleTaken so the handler can render
// a useful message.
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL, handle *string) (*model.User, error) {
const q = `
UPDATE users
SET name = COALESCE($2, name),
avatar_url = CASE WHEN $3::boolean THEN $4 ELSE avatar_url END,
handle = COALESCE($5, handle),
updated_at = now()
WHERE id = $1
RETURNING id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at`
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
sets := make([]string, 0, 10)
args := []any{id}
// $3 is a flag: "did the caller intend to set avatar_url (including to NULL)?"
// If avatarURL is nil we leave it alone; otherwise we apply *avatarURL (which
// may itself be the empty string — caller decides if "" should map to NULL).
updateAvatar := avatarURL != nil
var avatarVal *string
if updateAvatar && *avatarURL != "" {
avatarVal = avatarURL
// set appends "col = $N" with a bound value.
set := func(col string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
}
// setExpr appends a parameterless SET clause (e.g. "col = NULL").
setExpr := func(expr string) { sets = append(sets, expr) }
// nullableString handles the nil=leave / ""=clear / value=set trio.
nullableString := func(col string, v *string) {
if v == nil {
return
}
if *v == "" {
setExpr(col + " = NULL")
} else {
set(col, *v)
}
}
if p.Name != nil {
set("name", *p.Name)
}
if p.Handle != nil {
set("handle", *p.Handle)
}
nullableString("avatar_url", p.AvatarURL)
nullableString("country", p.Country)
nullableString("sampradaya", p.Sampradaya)
nullableString("gender", p.Gender)
nullableString("preferred_chanting_time", p.PreferredChantingTime)
nullableString("peak_active_time", p.PeakActiveTime)
if p.Initiated != nil {
set("initiated", *p.Initiated)
}
if p.InitiatedAt != nil {
if *p.InitiatedAt == "" {
setExpr("initiated_at = NULL")
} else {
args = append(args, *p.InitiatedAt)
sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args)))
}
}
if p.DailyChantingRounds != nil {
if *p.DailyChantingRounds == 0 {
setExpr("daily_chanting_rounds = NULL")
} else {
set("daily_chanting_rounds", *p.DailyChantingRounds)
}
}
// Nothing to change — return the current row rather than a no-op UPDATE.
if len(sets) == 0 {
return r.findOne(ctx, `WHERE id = $1`, id)
}
sets = append(sets, "updated_at = now()")
q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns
u := &model.User{}
err := r.pg.Pool.QueryRow(ctx, q, id, name, updateAvatar, avatarVal, handle).
Scan(&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt)
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrUserNotFound
@@ -201,6 +274,25 @@ func (r *UserRepo) UpdateProfile(ctx context.Context, id string, name, avatarURL
return u, nil
}
// SetCountryIfEmpty fills in the account country only when it's still
// NULL — used by the best-effort signup capture, so a later manual edit
// (or a second capture) never clobbers a value the user chose. `cc` is an
// ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the
// caller's to swallow (capture is fire-and-forget).
func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error {
cc = strings.ToUpper(strings.TrimSpace(cc))
if len(cc) != 2 {
return nil
}
_, err := r.pg.Pool.Exec(ctx, `
UPDATE users SET country = $2, updated_at = now()
WHERE id = $1 AND country IS NULL`, id, cc)
if err != nil {
return fmt.Errorf("set country: %w", err)
}
return nil
}
// UpdatePassword sets a new password hash and bumps token_version so any
// existing JWTs immediately become invalid.
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
@@ -240,9 +332,7 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use
substring := "%" + cleaned + "%"
prefix := cleaned + "%"
const sql = `
SELECT id, email, handle, password_hash, name, avatar_url, role, plan,
email_verified_at, token_version, created_at, updated_at
sql := `SELECT ` + userColumns + `
FROM users
WHERE name ILIKE $1 OR handle::text ILIKE $1
ORDER BY
@@ -262,11 +352,7 @@ func (r *UserRepo) Search(ctx context.Context, q string, limit int) ([]model.Use
out := []model.User{}
for rows.Next() {
var u model.User
if err := rows.Scan(
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL,
&u.Role, &u.Plan, &u.EmailVerifiedAt, &u.TokenVersion,
&u.CreatedAt, &u.UpdatedAt,
); err != nil {
if err := scanUser(rows, &u); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
out = append(out, u)