package user import ( "context" "encoding/json" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // UserRepository defines the persistence interface for user data. type UserRepository interface { UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) GetByID(ctx context.Context, id string) (*User, error) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error FindByRefreshToken(ctx context.Context, token string) (*User, error) ClearRefreshToken(ctx context.Context, id string) error } type Repository struct { pool *pgxpool.Pool } func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) { var avatarPtr *string if avatarURL != "" { avatarPtr = &avatarURL } query := ` INSERT INTO users (firebase_uid, email, name, avatar_url) VALUES ($1, $2, $3, $4) ON CONFLICT (firebase_uid) DO UPDATE SET email = EXCLUDED.email, name = CASE WHEN users.name = '' THEN EXCLUDED.name ELSE users.name END, avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), updated_at = now() RETURNING id, firebase_uid, email, name, avatar_url, height_cm, weight_kg, age, gender, activity, goal, daily_calories, plan, preferences, created_at, updated_at` return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr)) } func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) { query := ` SELECT id, firebase_uid, email, name, avatar_url, height_cm, weight_kg, age, gender, activity, goal, daily_calories, plan, preferences, created_at, updated_at FROM users WHERE id = $1` return r.scanUser(r.pool.QueryRow(ctx, query, id)) } func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) { query, args := buildUpdateQuery(id, req) if query == "" { return r.GetByID(ctx, id) } return r.scanUser(r.pool.QueryRow(ctx, query, args...)) } // buildUpdateQuery constructs a dynamic UPDATE query from the request fields. // Returns empty string if there are no fields to update. func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{}) { setClauses := []string{} args := []interface{}{} argIdx := 1 if req.Name != nil { setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) args = append(args, *req.Name) argIdx++ } if req.HeightCM != nil { setClauses = append(setClauses, fmt.Sprintf("height_cm = $%d", argIdx)) args = append(args, *req.HeightCM) argIdx++ } if req.WeightKG != nil { setClauses = append(setClauses, fmt.Sprintf("weight_kg = $%d", argIdx)) args = append(args, *req.WeightKG) argIdx++ } if req.Age != nil { setClauses = append(setClauses, fmt.Sprintf("age = $%d", argIdx)) args = append(args, *req.Age) argIdx++ } if req.Gender != nil { setClauses = append(setClauses, fmt.Sprintf("gender = $%d", argIdx)) args = append(args, *req.Gender) argIdx++ } if req.Activity != nil { setClauses = append(setClauses, fmt.Sprintf("activity = $%d", argIdx)) args = append(args, *req.Activity) argIdx++ } if req.Goal != nil { setClauses = append(setClauses, fmt.Sprintf("goal = $%d", argIdx)) args = append(args, *req.Goal) argIdx++ } if req.Preferences != nil { setClauses = append(setClauses, fmt.Sprintf("preferences = $%d", argIdx)) args = append(args, string(*req.Preferences)) argIdx++ } if req.DailyCalories != nil { setClauses = append(setClauses, fmt.Sprintf("daily_calories = $%d", argIdx)) args = append(args, *req.DailyCalories) argIdx++ } if len(setClauses) == 0 { return "", nil } setClauses = append(setClauses, "updated_at = now()") query := fmt.Sprintf(` UPDATE users SET %s WHERE id = $%d RETURNING id, firebase_uid, email, name, avatar_url, height_cm, weight_kg, age, gender, activity, goal, daily_calories, plan, preferences, created_at, updated_at`, strings.Join(setClauses, ", "), argIdx) args = append(args, id) return query, args } func (r *Repository) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) { tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback(ctx) // First update: profile fields query, args := buildUpdateQuery(id, profileReq) if query == "" { return nil, fmt.Errorf("no fields to update") } updated, err := r.scanUser(tx.QueryRow(ctx, query, args...)) if err != nil { return nil, fmt.Errorf("update profile: %w", err) } // Second update: calories (if provided) if caloriesReq != nil { query, args = buildUpdateQuery(id, *caloriesReq) if query != "" { updated, err = r.scanUser(tx.QueryRow(ctx, query, args...)) if err != nil { return nil, fmt.Errorf("update calories: %w", err) } } } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("commit tx: %w", err) } return updated, nil } func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error { query := `UPDATE users SET refresh_token = $2, token_expires_at = $3, updated_at = now() WHERE id = $1` _, err := r.pool.Exec(ctx, query, id, token, expiresAt) return err } func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) { query := ` SELECT id, firebase_uid, email, name, avatar_url, height_cm, weight_kg, age, gender, activity, goal, daily_calories, plan, preferences, created_at, updated_at FROM users WHERE refresh_token = $1 AND token_expires_at > now()` return r.scanUser(r.pool.QueryRow(ctx, query, token)) } func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error { query := `UPDATE users SET refresh_token = NULL, token_expires_at = NULL, updated_at = now() WHERE id = $1` _, err := r.pool.Exec(ctx, query, id) return err } type scannable interface { Scan(dest ...interface{}) error } func (r *Repository) scanUser(row scannable) (*User, error) { var u User var prefs []byte err := row.Scan( &u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL, &u.HeightCM, &u.WeightKG, &u.Age, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories, &u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { return nil, fmt.Errorf("scan user: %w", err) } u.Preferences = json.RawMessage(prefs) return &u, nil }