feat: social graph features
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/prabhupadasays/spsays-bff/internal/model"
|
||||
)
|
||||
|
||||
// social_graph_repo.go bundles the four small repos backing the social
|
||||
// graph — follows, blocks, reports, and notifications. Each is a thin
|
||||
// wrapper over the shared Postgres pool, mirroring the per-domain repo
|
||||
// convention used elsewhere (UserRepo, PostRepo, …). They're grouped in
|
||||
// one file because each is only a handful of queries and they share the
|
||||
// same dependency.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FollowRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FollowRepo manages the request-based follows edge table.
|
||||
type FollowRepo struct{ pg *Postgres }
|
||||
|
||||
func NewFollowRepo(pg *Postgres) *FollowRepo { return &FollowRepo{pg: pg} }
|
||||
|
||||
// Request inserts a pending follow edge follower→following. Idempotent:
|
||||
// a repeated request (the edge already exists, pending or accepted) is a
|
||||
// no-op. Returns created=true only when a brand-new pending row was
|
||||
// inserted, so the caller fires the "X requested to follow you"
|
||||
// notification exactly once.
|
||||
func (r *FollowRepo) Request(ctx context.Context, followerID, followingID string) (created bool, err error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO follows (follower_id, following_id, status)
|
||||
VALUES ($1, $2, 'pending')
|
||||
ON CONFLICT (follower_id, following_id) DO NOTHING`,
|
||||
followerID, followingID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("request follow: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// Accept flips a pending edge follower→following to accepted and stamps
|
||||
// responded_at. Returns accepted=true only on the transition from
|
||||
// pending (so the "X accepted your request" notification fires once); a
|
||||
// call on an already-accepted or absent edge returns false.
|
||||
func (r *FollowRepo) Accept(ctx context.Context, followerID, followingID string) (accepted bool, err error) {
|
||||
tag, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE follows
|
||||
SET status = 'accepted', responded_at = now()
|
||||
WHERE follower_id = $1 AND following_id = $2 AND status = 'pending'`,
|
||||
followerID, followingID,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("accept follow: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
// Delete removes the edge follower→following regardless of status. Backs
|
||||
// unfollow (accepted), cancel-request (own pending outgoing), reject
|
||||
// (their pending incoming), and remove-follower (their accepted edge to
|
||||
// me). Idempotent.
|
||||
func (r *FollowRepo) Delete(ctx context.Context, followerID, followingID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM follows WHERE follower_id = $1 AND following_id = $2`,
|
||||
followerID, followingID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete follow: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the status of the edge follower→following ('pending' or
|
||||
// 'accepted'), or "" when no edge exists.
|
||||
func (r *FollowRepo) Status(ctx context.Context, followerID, followingID string) (string, error) {
|
||||
var status string
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT status FROM follows WHERE follower_id = $1 AND following_id = $2`,
|
||||
followerID, followingID,
|
||||
).Scan(&status)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("follow status: %w", err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// CountFollowers counts accepted edges pointing at userID.
|
||||
func (r *FollowRepo) CountFollowers(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM follows WHERE following_id = $1 AND status = 'accepted'`,
|
||||
userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count followers: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// CountFollowing counts accepted edges originating from userID.
|
||||
func (r *FollowRepo) CountFollowing(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM follows WHERE follower_id = $1 AND status = 'accepted'`,
|
||||
userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count following: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlockRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BlockRepo manages user_blocks. Block() also tears down any follow
|
||||
// edges in either direction, so it runs in a transaction.
|
||||
type BlockRepo struct{ pg *Postgres }
|
||||
|
||||
func NewBlockRepo(pg *Postgres) *BlockRepo { return &BlockRepo{pg: pg} }
|
||||
|
||||
// Block records blocker→blocked (idempotent) and, in the same tx, deletes
|
||||
// any follow edges between the two in either direction — blocking someone
|
||||
// should sever the relationship both ways.
|
||||
func (r *BlockRepo) Block(ctx context.Context, blockerID, blockedID string) error {
|
||||
tx, err := r.pg.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin block tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO user_blocks (blocker_id, blocked_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (blocker_id, blocked_id) DO NOTHING`,
|
||||
blockerID, blockedID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert block: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM follows
|
||||
WHERE (follower_id = $1 AND following_id = $2)
|
||||
OR (follower_id = $2 AND following_id = $1)`,
|
||||
blockerID, blockedID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("sever follows on block: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit block: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unblock removes the blocker→blocked record. Idempotent.
|
||||
func (r *BlockRepo) Unblock(ctx context.Context, blockerID, blockedID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2`,
|
||||
blockerID, blockedID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unblock: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockState reports, in one round-trip, whether a blocked b and whether
|
||||
// b blocked a.
|
||||
func (r *BlockRepo) BlockState(ctx context.Context, a, b string) (aBlockedB, bBlockedA bool, err error) {
|
||||
err = r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT
|
||||
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2),
|
||||
EXISTS(SELECT 1 FROM user_blocks WHERE blocker_id = $2 AND blocked_id = $1)`,
|
||||
a, b,
|
||||
).Scan(&aBlockedB, &bBlockedA)
|
||||
if err != nil {
|
||||
return false, false, fmt.Errorf("block state: %w", err)
|
||||
}
|
||||
return aBlockedB, bBlockedA, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReportRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ReportRepo manages user_reports.
|
||||
type ReportRepo struct{ pg *Postgres }
|
||||
|
||||
func NewReportRepo(pg *Postgres) *ReportRepo { return &ReportRepo{pg: pg} }
|
||||
|
||||
// Create inserts a user-report row.
|
||||
func (r *ReportRepo) Create(ctx context.Context, reporterID, reportedID, category, reason string) (*model.UserReport, error) {
|
||||
rep := &model.UserReport{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO user_reports (reporter_id, reported_id, category, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, reporter_id, reported_id, category, reason, created_at, resolved_at`,
|
||||
reporterID, reportedID, category, reason,
|
||||
).Scan(&rep.ID, &rep.ReporterID, &rep.ReportedID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create report: %w", err)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// CreatePostReport inserts a post-report row.
|
||||
func (r *ReportRepo) CreatePostReport(ctx context.Context, reporterID, postID, category, reason string) (*model.PostReport, error) {
|
||||
rep := &model.PostReport{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO post_reports (reporter_id, post_id, category, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, reporter_id, post_id, category, reason, created_at, resolved_at`,
|
||||
reporterID, postID, category, reason,
|
||||
).Scan(&rep.ID, &rep.ReporterID, &rep.PostID, &rep.Category, &rep.Reason, &rep.CreatedAt, &rep.ResolvedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post report: %w", err)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotificationRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NotificationRepo persists notification rows. Reads (list / unread
|
||||
// count / mark-read) will be added when the in-app notification center
|
||||
// ships; for now the table is write-only, feeding the email fan-out and
|
||||
// standing ready for websocket/push.
|
||||
type NotificationRepo struct{ pg *Postgres }
|
||||
|
||||
func NewNotificationRepo(pg *Postgres) *NotificationRepo { return &NotificationRepo{pg: pg} }
|
||||
|
||||
// Create inserts a notification for recipient userID, triggered by
|
||||
// actorID (nil for system notifications), of the given kind.
|
||||
func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *string, kind string) (*model.Notification, error) {
|
||||
n := &model.Notification{}
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
INSERT INTO notifications (user_id, actor_id, kind)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, user_id, actor_id, kind, created_at, read_at`,
|
||||
userID, actorID, kind,
|
||||
).Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create notification: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ListByUser returns a recipient's notifications, newest first, with the
|
||||
// actor's display fields joined in for rendering (nil for system
|
||||
// notifications or since-deleted actors).
|
||||
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.created_at, n.read_at,
|
||||
u.name, u.handle, u.avatar_url
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_id
|
||||
WHERE n.user_id = $1
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Notification{}
|
||||
for rows.Next() {
|
||||
var n model.Notification
|
||||
if err := rows.Scan(&n.ID, &n.UserID, &n.ActorID, &n.Kind, &n.CreatedAt, &n.ReadAt,
|
||||
&n.ActorName, &n.ActorHandle, &n.ActorAvatar); err != nil {
|
||||
return nil, fmt.Errorf("scan notification: %w", err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountUnread returns how many of a recipient's notifications are unread.
|
||||
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count unread notifications: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkAllRead stamps read_at on every still-unread notification for the
|
||||
// user. Idempotent.
|
||||
func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL`, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark notifications read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user