Add bio field and daily streak check-in for gamification

- User.Bio: a short (300 char) freeform profile bio, editable via
  PATCH /profile/bio, shown on discover cards and candidate detail (richer
  profiles beyond the fixed onboarding fields).
- User.StreakCount/LastActiveDate: POST /profile/checkin increments the
  streak on consecutive days, resets after a gap, no-ops if already
  checked in today. Backs a daily-return hook on the frontend.
This commit is contained in:
2026-09-20 12:26:06 +03:30
parent 1dc96e86fc
commit c8e6f80c51
9 changed files with 110 additions and 0 deletions
+2
View File
@@ -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"`
}
+2
View File
@@ -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"`
+11
View File
@@ -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"`
}
+2
View File
@@ -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)
+32
View File
@@ -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)
}
+5
View File
@@ -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:"-"`
+3
View File
@@ -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)
}
// -------- آنبوردینگ (نیاز به ورود) --------
+9
View File
@@ -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,
+44
View File
@@ -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
}