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"`
|
State string `json:"state"`
|
||||||
BlockedReason *string `json:"blocked_reason,omitempty"`
|
BlockedReason *string `json:"blocked_reason,omitempty"`
|
||||||
BlockedAt *time.Time `json:"blocked_at,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"`
|
ID uint `json:"id"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
Age int `json:"age"`
|
Age int `json:"age"`
|
||||||
|
Bio string `json:"bio,omitempty"`
|
||||||
Photos []string `json:"photos"`
|
Photos []string `json:"photos"`
|
||||||
DistanceKM *float64 `json:"distance_km,omitempty"`
|
DistanceKM *float64 `json:"distance_km,omitempty"`
|
||||||
Interests []string `json:"interests,omitempty"`
|
Interests []string `json:"interests,omitempty"`
|
||||||
@@ -58,6 +59,7 @@ type CandidateDetail struct {
|
|||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
Age int `json:"age"`
|
Age int `json:"age"`
|
||||||
|
Bio string `json:"bio,omitempty"`
|
||||||
Photos []string `json:"photos"`
|
Photos []string `json:"photos"`
|
||||||
DistanceKM *float64 `json:"distance_km,omitempty"`
|
DistanceKM *float64 `json:"distance_km,omitempty"`
|
||||||
HeightCM *int `json:"height_cm,omitempty"`
|
HeightCM *int `json:"height_cm,omitempty"`
|
||||||
|
|||||||
@@ -5,3 +5,14 @@ type UpdateLocationRequest struct {
|
|||||||
Latitude float64 `json:"latitude" binding:"required"`
|
Latitude float64 `json:"latitude" binding:"required"`
|
||||||
Longitude float64 `json:"longitude" 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),
|
State: string(u.State),
|
||||||
BlockedReason: u.BlockedReason,
|
BlockedReason: u.BlockedReason,
|
||||||
BlockedAt: u.BlockedAt,
|
BlockedAt: u.BlockedAt,
|
||||||
|
Bio: u.Bio,
|
||||||
|
StreakCount: u.StreakCount,
|
||||||
}
|
}
|
||||||
if u.Gender != nil {
|
if u.Gender != nil {
|
||||||
g := string(*u.Gender)
|
g := string(*u.Gender)
|
||||||
|
|||||||
@@ -138,3 +138,35 @@ func (h *ProfileHandler) UpdateLocation(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil)
|
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"`
|
BirthDate *time.Time `json:"birth_date,omitempty"`
|
||||||
HeightCM *int `json:"height_cm,omitempty"`
|
HeightCM *int `json:"height_cm,omitempty"`
|
||||||
WeightKG *int `json:"weight_kg,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:"-"`
|
Latitude *float64 `json:"-"`
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ func Setup(
|
|||||||
profile.POST("/photos", h.Profile.UploadPhoto)
|
profile.POST("/photos", h.Profile.UploadPhoto)
|
||||||
profile.DELETE("/photos/:id", h.Profile.DeletePhoto)
|
profile.DELETE("/photos/:id", h.Profile.DeletePhoto)
|
||||||
profile.PATCH("/location", h.Profile.UpdateLocation)
|
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,
|
ID: wd.user.ID,
|
||||||
FirstName: wd.user.FirstName,
|
FirstName: wd.user.FirstName,
|
||||||
Age: utils.CalcAge(wd.user.BirthDate),
|
Age: utils.CalcAge(wd.user.BirthDate),
|
||||||
|
Bio: derefStr(wd.user.Bio),
|
||||||
// نبود عکس/علاقهمندی نباید توی JSON بهصورت null دربیاد؛ کلاینتها
|
// نبود عکس/علاقهمندی نباید توی JSON بهصورت null دربیاد؛ کلاینتها
|
||||||
// همیشه یک آرایه (حتی خالی) انتظار دارن، نه null.
|
// همیشه یک آرایه (حتی خالی) انتظار دارن، نه null.
|
||||||
Photos: orEmptyStrings(photosByUser[wd.user.ID]),
|
Photos: orEmptyStrings(photosByUser[wd.user.ID]),
|
||||||
@@ -154,6 +155,13 @@ func orEmptyStrings(s []string) []string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
// batchInterests علاقهمندیهای تمام کاندیداهای فید رو در یک کوئری واحد
|
// batchInterests علاقهمندیهای تمام کاندیداهای فید رو در یک کوئری واحد
|
||||||
// میگیره (بهجای یک کوئری جدا بهازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰
|
// میگیره (بهجای یک کوئری جدا بهازای هر کاندیدا)؛ روی فید دیسکاوری که تا ۶۰
|
||||||
// کاربر با هم پردازش میشن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد میکنه.
|
// کاربر با هم پردازش میشن، این تفاوت بین ۱ کوئری و تا ۶۰ کوئری رو ایجاد میکنه.
|
||||||
@@ -252,6 +260,7 @@ func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint
|
|||||||
ID: target.ID,
|
ID: target.ID,
|
||||||
FirstName: target.FirstName,
|
FirstName: target.FirstName,
|
||||||
Age: utils.CalcAge(target.BirthDate),
|
Age: utils.CalcAge(target.BirthDate),
|
||||||
|
Bio: derefStr(target.Bio),
|
||||||
Photos: urls,
|
Photos: urls,
|
||||||
DistanceKM: distance,
|
DistanceKM: distance,
|
||||||
HeightCM: target.HeightCM,
|
HeightCM: target.HeightCM,
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dating-app/internal/dto"
|
||||||
"dating-app/internal/models"
|
"dating-app/internal/models"
|
||||||
"dating-app/internal/repository"
|
"dating-app/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -97,3 +100,44 @@ func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) err
|
|||||||
"longitude": lng,
|
"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