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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// -------- آنبوردینگ (نیاز به ورود) --------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user