diff --git a/internal/dto/auth_dto.go b/internal/dto/auth_dto.go index 9fb4e01..fe2f564 100644 --- a/internal/dto/auth_dto.go +++ b/internal/dto/auth_dto.go @@ -49,4 +49,6 @@ type UserProfile struct { State string `json:"state"` BlockedReason *string `json:"blocked_reason,omitempty"` BlockedAt *time.Time `json:"blocked_at,omitempty"` + Bio *string `json:"bio,omitempty"` + StreakCount int `json:"streak_count"` } diff --git a/internal/dto/discovery_dto.go b/internal/dto/discovery_dto.go index b85454c..5490fdb 100644 --- a/internal/dto/discovery_dto.go +++ b/internal/dto/discovery_dto.go @@ -5,6 +5,7 @@ type DiscoverCandidate struct { ID uint `json:"id"` FirstName string `json:"first_name"` Age int `json:"age"` + Bio string `json:"bio,omitempty"` Photos []string `json:"photos"` DistanceKM *float64 `json:"distance_km,omitempty"` Interests []string `json:"interests,omitempty"` @@ -58,6 +59,7 @@ type CandidateDetail struct { ID uint `json:"id"` FirstName string `json:"first_name"` Age int `json:"age"` + Bio string `json:"bio,omitempty"` Photos []string `json:"photos"` DistanceKM *float64 `json:"distance_km,omitempty"` HeightCM *int `json:"height_cm,omitempty"` diff --git a/internal/dto/profile_dto.go b/internal/dto/profile_dto.go index d97f5aa..a1e536b 100644 --- a/internal/dto/profile_dto.go +++ b/internal/dto/profile_dto.go @@ -5,3 +5,14 @@ type UpdateLocationRequest struct { Latitude float64 `json:"latitude" binding:"required"` Longitude float64 `json:"longitude" binding:"required"` } + +// UpdateBioRequest ویرایش بیوگرافی کوتاه پروفایل +type UpdateBioRequest struct { + Bio string `json:"bio" binding:"max=300"` +} + +// CheckInResult نتیجه‌ی ثبت ورود روزانه (برای استریک گیمیفیکیشن) +type CheckInResult struct { + StreakCount int `json:"streak_count"` + IsNewToday bool `json:"is_new_today"` +} diff --git a/internal/handler/common.go b/internal/handler/common.go index e821260..a77355a 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -43,6 +43,8 @@ func toUserProfile(u *models.User) dto.UserProfile { State: string(u.State), BlockedReason: u.BlockedReason, BlockedAt: u.BlockedAt, + Bio: u.Bio, + StreakCount: u.StreakCount, } if u.Gender != nil { g := string(*u.Gender) diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go index de679d3..4dec35c 100644 --- a/internal/handler/profile_handler.go +++ b/internal/handler/profile_handler.go @@ -138,3 +138,35 @@ func (h *ProfileHandler) UpdateLocation(c *gin.Context) { } utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil) } + +// UpdateBio PATCH /profile/bio body: {bio} +func (h *ProfileHandler) UpdateBio(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + var req dto.UpdateBioRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "بیوگرافی نباید بیشتر از ۳۰۰ کاراکتر باشه", err.Error()) + return + } + if err := h.profileService.UpdateBio(user, req.Bio); err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی بیوگرافی", nil) + return + } + utils.Success(c, http.StatusOK, "بیوگرافی ذخیره شد", nil) +} + +// CheckIn POST /profile/checkin — ثبت ورود روزانه برای استریک گیمیفیکیشن +func (h *ProfileHandler) CheckIn(c *gin.Context) { + user, ok := currentUser(c) + if !ok { + return + } + result, err := h.profileService.CheckIn(user) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در ثبت ورود روزانه", nil) + return + } + utils.Success(c, http.StatusOK, "", result) +} diff --git a/internal/models/user.go b/internal/models/user.go index e9e67c7..b26358a 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -91,6 +91,11 @@ type User struct { BirthDate *time.Time `json:"birth_date,omitempty"` HeightCM *int `json:"height_cm,omitempty"` WeightKG *int `json:"weight_kg,omitempty"` + Bio *string `gorm:"size:300" json:"bio,omitempty"` + + // -------- گیمیفیکیشن: استریک روزانه (چند روز پشت‌سرهم اپ باز شده) -------- + StreakCount int `gorm:"default:0;not null" json:"streak_count"` + LastActiveDate *time.Time `json:"last_active_date,omitempty"` // -------- موقعیت مکانی (برای محاسبه‌ی فاصله‌ی تقریبی در دوستیابی) -------- Latitude *float64 `json:"-"` diff --git a/internal/router/router.go b/internal/router/router.go index da8c27a..6a9c8b5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -58,6 +58,9 @@ func Setup( profile.POST("/photos", h.Profile.UploadPhoto) profile.DELETE("/photos/:id", h.Profile.DeletePhoto) profile.PATCH("/location", h.Profile.UpdateLocation) + profile.PATCH("/bio", h.Profile.UpdateBio) + // استریک روزانه (گیمیفیکیشن): هر بار اپ باز می‌شه یه بار صدا زده می‌شه + profile.POST("/checkin", h.Profile.CheckIn) } // -------- آنبوردینگ (نیاز به ورود) -------- diff --git a/internal/service/discovery_service.go b/internal/service/discovery_service.go index f4a42f3..4c8bca3 100644 --- a/internal/service/discovery_service.go +++ b/internal/service/discovery_service.go @@ -137,6 +137,7 @@ func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, mi ID: wd.user.ID, FirstName: wd.user.FirstName, Age: utils.CalcAge(wd.user.BirthDate), + Bio: derefStr(wd.user.Bio), // نبود عکس/علاقه‌مندی نباید توی JSON به‌صورت null دربیاد؛ کلاینت‌ها // همیشه یک آرایه (حتی خالی) انتظار دارن، نه null. Photos: orEmptyStrings(photosByUser[wd.user.ID]), @@ -154,6 +155,13 @@ func orEmptyStrings(s []string) []string { return s } +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + // batchInterests علاقه‌مندی‌های تمام کاندیداهای فید رو در یک کوئری واحد // می‌گیره (به‌جای یک کوئری جدا به‌ازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰ // کاربر با هم پردازش می‌شن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد می‌کنه. @@ -252,6 +260,7 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint ID: target.ID, FirstName: target.FirstName, Age: utils.CalcAge(target.BirthDate), + Bio: derefStr(target.Bio), Photos: urls, DistanceKM: distance, HeightCM: target.HeightCM, diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 4447865..9e4c09b 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -2,7 +2,10 @@ package service import ( "errors" + "strings" + "time" + "dating-app/internal/dto" "dating-app/internal/models" "dating-app/internal/repository" ) @@ -97,3 +100,44 @@ func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) err "longitude": lng, }) } + +// UpdateBio بیوگرافی کوتاه پروفایل رو ذخیره می‌کنه (طول توسط binding در DTO چک شده) +func (s *ProfileService) UpdateBio(user *models.User, bio string) error { + bio = strings.TrimSpace(bio) + if err := s.userRepo.UpdateFields(user, map[string]interface{}{"bio": bio}); err != nil { + return err + } + user.Bio = &bio + return nil +} + +// CheckIn ورود روزانه‌ی کاربر رو ثبت می‌کنه و استریک (چند روز پشت‌سرهم) رو +// به‌روز می‌کنه: اگه دیروز هم اومده بوده، استریک +۱ می‌شه؛ اگه امروز قبلاً +// چک‌این کرده، چیزی تغییر نمی‌کنه؛ وگرنه (وقفه افتاده) استریک از ۱ شروع می‌شه. +func (s *ProfileService) CheckIn(user *models.User) (*dto.CheckInResult, error) { + now := time.Now() + today := now.Format("2006-01-02") + + if user.LastActiveDate != nil && user.LastActiveDate.Format("2006-01-02") == today { + return &dto.CheckInResult{StreakCount: user.StreakCount, IsNewToday: false}, nil + } + + newStreak := 1 + if user.LastActiveDate != nil { + yesterday := now.AddDate(0, 0, -1).Format("2006-01-02") + if user.LastActiveDate.Format("2006-01-02") == yesterday { + newStreak = user.StreakCount + 1 + } + } + + if err := s.userRepo.UpdateFields(user, map[string]interface{}{ + "streak_count": newStreak, + "last_active_date": now, + }); err != nil { + return nil, err + } + user.StreakCount = newStreak + user.LastActiveDate = &now + + return &dto.CheckInResult{StreakCount: newStreak, IsNewToday: true}, nil +}