Skip to content

Commit f602355

Browse files
added public profile
1 parent 8a95e0f commit f602355

File tree

8 files changed

+221
-17
lines changed

8 files changed

+221
-17
lines changed

internal/handlers/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ func (s *Server) setupRoutes() {
163163
protected.GET("/profile", userHandler.GetProfile)
164164
protected.GET("/users/:id", middleware.AuthorizeSelf(), userHandler.GetUser)
165165
protected.GET("/users", middleware.RestrictToRoles("admin", "superuser"), userHandler.GetAllUsers)
166+
protected.GET("/users/search", userHandler.SearchUsers)
167+
protected.GET("/users/:id/profile", userHandler.GetPublicProfile)
166168
protected.PUT("/users/:id", middleware.AuthorizeSelf(), userHandler.UpdateUser)
167169
protected.DELETE("/users/:id", middleware.AuthorizeSelf(), userHandler.DeleteUser)
168170
protected.PATCH("/users/:id/password", userHandler.PatchPassword)

internal/handlers/user.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,77 @@ func (h *UserHandler) PatchAccount(c *gin.Context) {
379379
"user": user,
380380
})
381381
}
382+
383+
// @Summary Get public user profile
384+
// @Description Get public profile information for any user (no sensitive data)
385+
// @Tags Users
386+
// @Produce json
387+
// @Param id path int true "User ID"
388+
// @Success 200 {object} map[string]interface{} "Public profile retrieved successfully"
389+
// @Failure 400 {object} map[string]string "Bad request"
390+
// @Failure 404 {object} map[string]string "User not found"
391+
// @Router /api/v1/users/{id}/profile [get]
392+
func (h *UserHandler) GetPublicProfile(c *gin.Context) {
393+
idParam := c.Param("id")
394+
userID, err := strconv.ParseUint(idParam, 10, 32)
395+
if err != nil {
396+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user ID"})
397+
return
398+
}
399+
400+
var viewerID *uint
401+
if rawViewerID, exists := c.Get("user_id"); exists {
402+
if vID, ok := rawViewerID.(uint); ok {
403+
viewerID = &vID
404+
}
405+
}
406+
407+
profile, err := h.userService.GetPublicUserProfile(uint(userID), viewerID)
408+
if err != nil {
409+
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
410+
return
411+
}
412+
413+
stats, _ := h.userService.GetUserProfileStats(uint(userID))
414+
415+
c.Header("Cache-Control", "public, max-age=300") // Cache for 5 minutes
416+
c.JSON(http.StatusOK, gin.H{
417+
"profile": profile,
418+
"stats": stats,
419+
})
420+
}
421+
422+
// @Summary Search users
423+
// @Description Search for users by username or name
424+
// @Tags Users
425+
// @Produce json
426+
// @Param q query string true "Search query"
427+
// @Param limit query int false "Maximum results" default(20)
428+
// @Success 200 {object} map[string]interface{} "Search results"
429+
// @Failure 400 {object} map[string]string "Bad request"
430+
// @Router /api/v1/users/search [get]
431+
func (h *UserHandler) SearchUsers(c *gin.Context) {
432+
query := c.Query("q")
433+
if query == "" || len(query) < 2 {
434+
c.JSON(http.StatusBadRequest, gin.H{"error": "query must be at least 2 characters"})
435+
return
436+
}
437+
438+
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
439+
if limit > 50 {
440+
limit = 50
441+
}
442+
443+
profiles, err := h.userService.SearchPublicUsers(query, limit)
444+
if err != nil {
445+
c.JSON(http.StatusInternalServerError, gin.H{"error": "search failed"})
446+
return
447+
}
448+
449+
c.Header("Cache-Control", "public, max-age=300")
450+
c.JSON(http.StatusOK, gin.H{
451+
"users": profiles,
452+
"count": len(profiles),
453+
"query": query,
454+
})
455+
}

internal/models/user.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ type User struct {
3939
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
4040
}
4141

42+
type PublicUserProfile struct {
43+
ID uint `json:"id"`
44+
Username string `json:"username"`
45+
FirstName string `json:"first_name"`
46+
LastName string `json:"last_name"`
47+
AvatarURL *string `json:"avatar_url"`
48+
Location *string `json:"location"`
49+
FavoriteGenres pq.StringArray `json:"favorite_genres"`
50+
Bio *string `json:"bio"`
51+
BooksRead int `json:"books_read"`
52+
Badges pq.StringArray `json:"badges"`
53+
IsOnline bool `json:"is_online"`
54+
LastSeen *time.Time `json:"last_seen,omitempty"`
55+
JoinedAt time.Time `json:"joined_at"`
56+
57+
TotalPosts int `json:"total_posts"`
58+
TotalComments int `json:"total_comments"`
59+
ClubsCount int `json:"clubs_count"`
60+
ReadingStreak int `json:"reading_streak,omitempty"`
61+
}
62+
4263
type UserResponse struct {
4364
ID uint `json:"id"`
4465
Username string `json:"username"`
@@ -164,3 +185,21 @@ type ErrorResponse struct {
164185
type SuccessResponse struct {
165186
Message string `json:"message"`
166187
}
188+
189+
func (u *User) ToPublicProfile() PublicUserProfile {
190+
return PublicUserProfile{
191+
ID: u.ID,
192+
Username: u.Username,
193+
FirstName: u.FirstName,
194+
LastName: u.LastName,
195+
AvatarURL: u.AvatarURL,
196+
Location: u.Location,
197+
FavoriteGenres: u.FavoriteGenres,
198+
Bio: u.Bio,
199+
BooksRead: u.BooksRead,
200+
Badges: u.Badges,
201+
IsOnline: u.IsOnline,
202+
LastSeen: u.LastSeen,
203+
JoinedAt: u.CreatedAt,
204+
}
205+
}

internal/repository/interfaces.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type UserRepository interface {
1010
Update(user *models.User) error
1111
Delete(id uint) error
1212
List(limit, offset int) ([]*models.User, error)
13+
SearchByUsernameOrName(query string, limit int) ([]*models.User, error)
1314
GetByEmailIncludingDeleted(email string) (*models.User, error)
1415
GetByUsernameIncludingDeleted(username string) (*models.User, error)
1516
}

internal/repository/mocks/mock_repositories.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/repository/user.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,20 @@ func (r *userRepository) List(limit, offset int) ([]*models.User, error) {
7777
return nil, err
7878
}
7979
return users, nil
80+
}
81+
82+
func (r *userRepository) SearchByUsernameOrName(query string, limit int) ([]*models.User, error) {
83+
var users []*models.User
84+
85+
err := r.db.Where("is_active = ?", true).
86+
Where("LOWER(username) LIKE LOWER(?) OR LOWER(first_name) LIKE LOWER(?) OR LOWER(last_name) LIKE LOWER(?)",
87+
"%"+query+"%", "%"+query+"%", "%"+query+"%").
88+
Limit(limit).
89+
Find(&users).Error
90+
91+
if err != nil {
92+
return nil, err
93+
}
94+
95+
return users, nil
8096
}

internal/repository/user_cache.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,7 @@ func (r *cachedUserRepository) Delete(id uint) error {
240240
func (r *cachedUserRepository) List(limit, offset int) ([]*models.User, error) {
241241
return r.base.List(limit, offset)
242242
}
243+
244+
func (r *cachedUserRepository) SearchByUsernameOrName(query string, limit int) ([]*models.User, error) {
245+
return r.base.SearchByUsernameOrName(query, limit)
246+
}

internal/services/user.go

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313

1414
type UserService struct {
1515
userRepo repository.UserRepository
16-
config *config.Config
16+
config *config.Config
1717
}
1818

1919
func NewUserService(userRepo repository.UserRepository, config *config.Config) *UserService {
@@ -44,18 +44,18 @@ func (s *UserService) Register(req *models.RegisterRequest) (*models.UserRespons
4444
}
4545

4646
user := &models.User{
47-
Username: req.Username,
48-
Email: req.Email,
49-
PasswordHash: hashedPassword,
50-
FirstName: req.FirstName,
51-
LastName: req.LastName,
52-
IsActive: true,
53-
Role: req.Role,
54-
AvatarURL: &req.AvatarURL,
55-
Location: &req.Location,
47+
Username: req.Username,
48+
Email: req.Email,
49+
PasswordHash: hashedPassword,
50+
FirstName: req.FirstName,
51+
LastName: req.LastName,
52+
IsActive: true,
53+
Role: req.Role,
54+
AvatarURL: &req.AvatarURL,
55+
Location: &req.Location,
5656
FavoriteGenres: req.FavoriteGenres,
57-
Bio: &req.Bio,
58-
ReadingGoal: req.ReadingGoal,
57+
Bio: &req.Bio,
58+
ReadingGoal: req.ReadingGoal,
5959
}
6060

6161
if err := s.userRepo.Create(user); err != nil {
@@ -85,9 +85,9 @@ func (s *UserService) Login(req *models.LoginRequest) (string, *models.UserRespo
8585

8686
token, err := utils.GenerateJWT(
8787
user.ID,
88-
user.Email,
89-
user.Role,
90-
s.config.JWT.Secret,
88+
user.Email,
89+
user.Role,
90+
s.config.JWT.Secret,
9191
s.config.JWT.ExpirationHours,
9292
)
9393
if err != nil {
@@ -112,6 +112,69 @@ func (s *UserService) GetUserByID(id uint) (*models.UserResponse, error) {
112112
return &response, nil
113113
}
114114

115+
func (s *UserService) GetPublicUserProfile(userID uint, viewerID *uint) (*models.PublicUserProfile, error) {
116+
user, err := s.userRepo.GetByID(userID)
117+
if err != nil {
118+
if errors.Is(err, gorm.ErrRecordNotFound) {
119+
return nil, errors.New("user not found")
120+
}
121+
return nil, err
122+
}
123+
124+
if !user.IsActive {
125+
return nil, errors.New("user profile is not available")
126+
}
127+
128+
profile := user.ToPublicProfile()
129+
130+
profile.TotalPosts = len(user.Posts)
131+
profile.TotalComments = len(user.Comments)
132+
profile.ClubsCount = len(user.ClubMemberships)
133+
134+
return &profile, nil
135+
}
136+
137+
func (s *UserService) GetUserProfileStats(userID uint) (map[string]interface{}, error) {
138+
user, err := s.userRepo.GetByID(userID)
139+
if err != nil {
140+
if errors.Is(err, gorm.ErrRecordNotFound) {
141+
return nil, errors.New("user not found")
142+
}
143+
return nil, err
144+
}
145+
146+
stats := map[string]interface{}{
147+
"total_posts": len(user.Posts),
148+
"total_comments": len(user.Comments),
149+
"clubs_joined": len(user.ClubMemberships),
150+
"books_read": user.BooksRead,
151+
"profile_views": 0,
152+
"member_since": user.CreatedAt.Format("January 2006"),
153+
}
154+
155+
return stats, nil
156+
}
157+
158+
func (s *UserService) SearchPublicUsers(query string, limit int) ([]models.PublicUserProfile, error) {
159+
if limit <= 0 || limit > 50 {
160+
limit = 20
161+
}
162+
163+
users, err := s.userRepo.SearchByUsernameOrName(query, limit)
164+
if err != nil {
165+
return nil, err
166+
}
167+
168+
var profiles []models.PublicUserProfile
169+
for _, user := range users {
170+
if user.IsActive {
171+
profiles = append(profiles, user.ToPublicProfile())
172+
}
173+
}
174+
175+
return profiles, nil
176+
}
177+
115178
func (s *UserService) GetAllUsers() ([]models.UserResponse, error) {
116179
users, err := s.userRepo.List(50, 0)
117180
if err != nil {
@@ -216,7 +279,7 @@ func (s *UserService) DeleteUser(id uint) error {
216279
if err := s.userRepo.Update(user); err != nil {
217280
return errors.New("failed to deactivate user")
218281
}
219-
282+
220283
err = s.userRepo.Delete(id)
221284
if err != nil {
222285
return err
@@ -340,4 +403,4 @@ func (s *UserService) UpdateAccount(id uint, req *models.UpdateAccountRequest) (
340403

341404
response := user.ToResponse()
342405
return &response, nil
343-
}
406+
}

0 commit comments

Comments
 (0)