Initial commit: dating app backend (Go/Gin/GORM/Postgres)

Phone+OTP auth, staged onboarding, admin approval workflow, and a
full discovery/swipe/match/chat system with photo upload and
approximate distance (haversine) based on user location.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 23:23:47 +03:30
commit 2823fb45f5
49 changed files with 4582 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
package service
import (
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است")
type AdminService struct {
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
userRepo *repository.UserRepository
}
func NewAdminService(
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
userRepo *repository.UserRepository,
) *AdminService {
return &AdminService{optionRepo: optionRepo, questionRepo: questionRepo, userRepo: userRepo}
}
// ---------------- بررسی و تایید کاربران ----------------
// GetUser یک کاربر رو برای نمایش به ادمین (بررسی درخواست) برمی‌گردونه
func (s *AdminService) GetUser(id uint) (*models.User, error) {
return s.userRepo.FindByID(id)
}
// ListPendingUsers کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی‌اند
func (s *AdminService) ListPendingUsers() ([]models.User, error) {
return s.userRepo.ListPendingApproval()
}
// ApproveUser کاربر رو تایید می‌کنه تا بتونه وارد صفحه‌ی دوستیابی بشه
func (s *AdminService) ApproveUser(id uint) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
if !user.IsOnboarded {
return nil, ErrUserNotOnboarded
}
now := time.Now()
updates := map[string]interface{}{
"approval_status": models.ApprovalApproved,
"rejection_reason": nil,
"reviewed_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.ApprovalStatus = models.ApprovalApproved
user.RejectionReason = nil
user.ReviewedAt = &now
return user, nil
}
// RejectUser درخواست عضویت کاربر رو با یک دلیل مشخص رد می‌کنه
func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) {
user, err := s.userRepo.FindByID(id)
if err != nil {
return nil, err
}
if !user.IsOnboarded {
return nil, ErrUserNotOnboarded
}
now := time.Now()
updates := map[string]interface{}{
"approval_status": models.ApprovalRejected,
"rejection_reason": reason,
"reviewed_at": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, err
}
user.ApprovalStatus = models.ApprovalRejected
user.RejectionReason = &reason
user.ReviewedAt = &now
return user, nil
}
// ---------------- دسته‌ها و گزینه‌ها ----------------
func (s *AdminService) GetCategory(key models.CategoryKey) (*models.Category, error) {
return s.optionRepo.GetCategoryByKey(key)
}
func (s *AdminService) UpdateCategory(key models.CategoryKey, req dto.UpdateCategoryRequest) (*models.Category, error) {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil, err
}
if req.Title != nil {
cat.Title = *req.Title
}
if req.MinSelect != nil {
cat.MinSelect = *req.MinSelect
}
if req.MaxSelect != nil {
cat.MaxSelect = *req.MaxSelect
}
if err := s.optionRepo.UpdateCategory(cat); err != nil {
return nil, err
}
return cat, nil
}
func (s *AdminService) ListOptions(key models.CategoryKey) ([]models.Option, error) {
return s.optionRepo.ListOptionsByCategoryKeyAdmin(key)
}
func (s *AdminService) CreateOption(key models.CategoryKey, req dto.CreateOptionRequest) (*models.Option, error) {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil, err
}
opt := &models.Option{
CategoryID: cat.ID,
Title: req.Title,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := s.optionRepo.CreateOption(opt); err != nil {
return nil, err
}
return opt, nil
}
func (s *AdminService) UpdateOption(id uint, req dto.UpdateOptionRequest) (*models.Option, error) {
opt, err := s.optionRepo.FindOptionByID(id)
if err != nil {
return nil, err
}
if req.Title != nil {
opt.Title = *req.Title
}
if req.SortOrder != nil {
opt.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
opt.IsActive = *req.IsActive
}
if err := s.optionRepo.UpdateOption(opt); err != nil {
return nil, err
}
return opt, nil
}
func (s *AdminService) DeleteOption(id uint) error {
return s.optionRepo.DeleteOption(id)
}
// ---------------- سوالات پروفایل ----------------
func (s *AdminService) ListQuestions() ([]models.ProfileQuestion, error) {
return s.questionRepo.ListAllAdmin()
}
func (s *AdminService) CreateQuestion(req dto.CreateProfileQuestionRequest) (*models.ProfileQuestion, error) {
q := &models.ProfileQuestion{
Text: req.Text,
SortOrder: req.SortOrder,
IsActive: true,
}
if err := s.questionRepo.Create(q); err != nil {
return nil, err
}
return q, nil
}
func (s *AdminService) UpdateQuestion(id uint, req dto.UpdateProfileQuestionRequest) (*models.ProfileQuestion, error) {
q, err := s.questionRepo.FindByID(id)
if err != nil {
return nil, err
}
if req.Text != nil {
q.Text = *req.Text
}
if req.SortOrder != nil {
q.SortOrder = *req.SortOrder
}
if req.IsActive != nil {
q.IsActive = *req.IsActive
}
if err := s.questionRepo.Update(q); err != nil {
return nil, err
}
return q, nil
}
func (s *AdminService) DeleteQuestion(id uint) error {
return s.questionRepo.Delete(id)
}
+187
View File
@@ -0,0 +1,187 @@
package service
import (
"errors"
"time"
"gorm.io/gorm"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
var (
ErrPhoneBlocked = errors.New("حساب کاربری مسدود شده است")
ErrOTPStillValid = errors.New("کد تایید قبلی هنوز معتبر است")
ErrOTPNotRequested = errors.New("ابتدا باید کد تایید را درخواست کنید")
ErrOTPExpired = errors.New("کد تایید منقضی شده است")
ErrOTPInvalid = errors.New("کد وارد شده نامعتبر است")
ErrSMSSendFailed = errors.New("ارسال پیامک با خطا مواجه شد؛ لطفاً چند لحظه دیگر دوباره تلاش کنید")
ErrUserNotFound = errors.New("کاربر یافت نشد")
)
// OTPStillValidError برای برگردوندن تعداد ثانیه‌ی باقی‌مانده تا انقضای کد قبلی
type OTPStillValidError struct {
RemainingSeconds int
}
func (e *OTPStillValidError) Error() string {
return "کد تایید قبلی هنوز معتبر است"
}
type AuthService struct {
userRepo *repository.UserRepository
otpSender OTPSender
jwtManager *utils.JWTManager
otpExpiry time.Duration
otpLength int
otpHashSecret string
}
func NewAuthService(
userRepo *repository.UserRepository,
otpSender OTPSender,
jwtManager *utils.JWTManager,
otpExpirySeconds int,
otpLength int,
otpHashSecret string,
) *AuthService {
return &AuthService{
userRepo: userRepo,
otpSender: otpSender,
jwtManager: jwtManager,
otpExpiry: time.Duration(otpExpirySeconds) * time.Second,
otpLength: otpLength,
otpHashSecret: otpHashSecret,
}
}
// RequestOTP یک کد تایید جدید تولید، هش و ذخیره می‌کند و از طریق OTPSender ارسال می‌کند.
func (s *AuthService) RequestOTP(phone string) error {
if err := utils.ValidatePhone(phone); err != nil {
return err
}
user, _, err := s.userRepo.GetOrCreateByPhone(phone)
if err != nil {
return err
}
if user.State == models.UserStateBlocked {
return ErrPhoneBlocked
}
// اگر کد قبلی هنوز معتبره، اجازه‌ی ارسال دوباره نده
if user.OTPCode != nil && user.OTPExpiry != nil && time.Now().Before(*user.OTPExpiry) {
remaining := int(time.Until(*user.OTPExpiry).Seconds())
return &OTPStillValidError{RemainingSeconds: remaining}
}
otp := utils.GenerateOTP(s.otpLength)
hashed := utils.HashOTP(otp, s.otpHashSecret)
expiry := time.Now().Add(s.otpExpiry)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": hashed,
"otp_expiry": expiry,
}); err != nil {
return err
}
// ارسال پیامک به‌صورت sync؛ اگه واقعاً شکست خورد، صادقانه به کاربر خبر بده
// و کد ذخیره‌شده رو پاک کن تا کاربر بلافاصله بتونه دوباره تلاش کنه
// (وگرنه تا پایان OTPExpiry با پیام «کد قبلی هنوز معتبره» قفل می‌مونه).
if ok := s.otpSender.Send(phone, otp); !ok {
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
})
return ErrSMSSendFailed
}
return nil
}
// VerifyOTP کد را بررسی می‌کند و در صورت صحت، کاربر را فعال کرده و توکن صادر می‌کند.
func (s *AuthService) VerifyOTP(phone, otp string) (*models.User, string, string, bool, error) {
user, err := s.userRepo.FindByPhone(phone)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, "", "", false, ErrUserNotFound
}
return nil, "", "", false, err
}
if user.OTPCode == nil || user.OTPExpiry == nil {
return nil, "", "", false, ErrOTPNotRequested
}
if time.Now().After(*user.OTPExpiry) {
_ = s.userRepo.UpdateFields(user, map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
})
return nil, "", "", false, ErrOTPExpired
}
if *user.OTPCode != utils.HashOTP(otp, s.otpHashSecret) {
return nil, "", "", false, ErrOTPInvalid
}
isNewUser := user.State == models.UserStateInactive
now := time.Now()
updates := map[string]interface{}{
"otp_code": nil,
"otp_expiry": nil,
"state": models.UserStateActive,
"last_login": now,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return nil, "", "", false, err
}
// رفرش شیء user در حافظه برای برگردوندن مقادیر به‌روز
user.State = models.UserStateActive
user.LastLogin = &now
user.OTPCode = nil
user.OTPExpiry = nil
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
if err != nil {
return nil, "", "", false, err
}
refresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
if err != nil {
return nil, "", "", false, err
}
return user, access, refresh, isNewUser, nil
}
// RefreshTokens با یک refresh token معتبر، یک جفت توکن جدید صادر می‌کند.
func (s *AuthService) RefreshTokens(refreshToken string) (string, string, error) {
claims, err := s.jwtManager.ParseRefreshToken(refreshToken)
if err != nil {
return "", "", err
}
user, err := s.userRepo.FindByID(claims.UserID)
if err != nil {
return "", "", ErrUserNotFound
}
if user.State == models.UserStateBlocked || !user.IsActive {
return "", "", ErrPhoneBlocked
}
access, err := s.jwtManager.GenerateAccessToken(user.ID, user.IsAdmin)
if err != nil {
return "", "", err
}
newRefresh, err := s.jwtManager.GenerateRefreshToken(user.ID, user.IsAdmin)
if err != nil {
return "", "", err
}
return access, newRefresh, nil
}
+126
View File
@@ -0,0 +1,126 @@
package service
import (
"errors"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
type ChatService struct {
matchRepo *repository.MatchRepository
messageRepo *repository.MessageRepository
userRepo *repository.UserRepository
photoRepo *repository.PhotoRepository
}
func NewChatService(
matchRepo *repository.MatchRepository,
messageRepo *repository.MessageRepository,
userRepo *repository.UserRepository,
photoRepo *repository.PhotoRepository,
) *ChatService {
return &ChatService{
matchRepo: matchRepo,
messageRepo: messageRepo,
userRepo: userRepo,
photoRepo: photoRepo,
}
}
// ListMatches همه‌ی مچ‌های کاربر رو همراه با اطلاعات طرف مقابل و آخرین پیام برمی‌گردونه
func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
matches, err := s.matchRepo.ListByUser(userID)
if err != nil {
return nil, err
}
views := make([]dto.MatchView, 0, len(matches))
for _, m := range matches {
otherID := repository.OtherUserID(&m, userID)
other, err := s.userRepo.FindByID(otherID)
if err != nil {
continue
}
photos, _ := s.photoRepo.ListByUser(otherID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
view := dto.MatchView{
ID: m.ID,
UserID: other.ID,
FirstName: other.FirstName,
Photos: urls,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
}
if last, err := s.messageRepo.LastMessage(m.ID); err == nil {
text := last.Text
at := last.CreatedAt.Format(time.RFC3339)
view.LastMessage = &text
view.LastAt = &at
}
views = append(views, view)
}
return views, nil
}
func (s *ChatService) getAuthorizedMatch(userID, matchID uint) (uint, error) {
match, err := s.matchRepo.FindByID(matchID)
if err != nil {
return 0, err
}
if !repository.IsMember(match, userID) {
return 0, ErrNotMatchMember
}
return match.ID, nil
}
// ListMessages پیام‌های یک مچ رو برمی‌گردونه؛ فقط اگه کاربر عضو اون مچ باشه.
func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
messages, err := s.messageRepo.ListByMatch(matchID)
if err != nil {
return nil, err
}
views := make([]dto.MessageView, 0, len(messages))
for _, m := range messages {
views = append(views, dto.MessageView{
ID: m.ID,
MatchID: m.MatchID,
SenderID: m.SenderID,
Text: m.Text,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
IsMine: m.SenderID == userID,
})
}
return views, nil
}
// SendMessage یک پیام جدید توی یک مچ ثبت می‌کنه؛ فقط اگه کاربر عضو اون مچ باشه.
func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.MessageView, error) {
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
return nil, err
}
msg := &models.Message{MatchID: matchID, SenderID: userID, Text: text}
if err := s.messageRepo.Create(msg); err != nil {
return nil, err
}
return &dto.MessageView{
ID: msg.ID,
MatchID: msg.MatchID,
SenderID: msg.SenderID,
Text: msg.Text,
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
IsMine: true,
}, nil
}
+303
View File
@@ -0,0 +1,303 @@
package service
import (
"errors"
"math"
"sort"
"time"
"gorm.io/gorm"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
"dating-app/internal/utils"
)
const (
DefaultMinAge = 18
DefaultMaxAge = 60
DiscoverBatchSize = 60
DiscoverResultSize = 20
)
var (
ErrGenderNotSet = errors.New("برای دیدن پیشنهادها اول باید جنسیتت مشخص شده باشه")
ErrCannotSwipeSelf = errors.New("نمی‌تونی روی خودت سواپ بزنی")
ErrAlreadySwiped = errors.New("قبلاً روی این پروفایل تصمیم گرفتی")
)
// جنسیت مخالف کاربر رو برمی‌گردونه. این ساده‌ترین فرض ممکنه (هترو دیفالت)؛
// سیستم فعلاً فیلد جداگانه‌ای برای «ترجیح جنسیتی/گرایش» نداره.
func oppositeGender(g models.Gender) models.Gender {
if g == models.GenderFemale {
return models.GenderMale
}
return models.GenderFemale
}
type DiscoveryService struct {
userRepo *repository.UserRepository
photoRepo *repository.PhotoRepository
swipeRepo *repository.SwipeRepository
matchRepo *repository.MatchRepository
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
}
func NewDiscoveryService(
userRepo *repository.UserRepository,
photoRepo *repository.PhotoRepository,
swipeRepo *repository.SwipeRepository,
matchRepo *repository.MatchRepository,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
) *DiscoveryService {
return &DiscoveryService{
userRepo: userRepo,
photoRepo: photoRepo,
swipeRepo: swipeRepo,
matchRepo: matchRepo,
optionRepo: optionRepo,
questionRepo: questionRepo,
}
}
type candidateWithDistance struct {
user models.User
distance *float64
}
// GetFeed لیست پیشنهادهای صفحه‌ی دوستیابی رو برمی‌گردونه؛ نزدیک‌ترین‌ها اول.
func (s *DiscoveryService) GetFeed(user *models.User, maxDistanceKM *float64, minAge, maxAge int) ([]dto.DiscoverCandidate, error) {
if user.Gender == nil {
return nil, ErrGenderNotSet
}
if minAge <= 0 {
minAge = DefaultMinAge
}
if maxAge <= 0 || maxAge < minAge {
maxAge = DefaultMaxAge
}
candidates, err := s.userRepo.ListDiscoverable(user.ID, oppositeGender(*user.Gender), minAge, maxAge, DiscoverBatchSize)
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return []dto.DiscoverCandidate{}, nil
}
ids := make([]uint, len(candidates))
for i, c := range candidates {
ids[i] = c.ID
}
photos, err := s.photoRepo.ListByUserIDs(ids)
if err != nil {
return nil, err
}
photosByUser := map[uint][]string{}
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
interestsByUser := s.batchInterests(ids)
withDist := make([]candidateWithDistance, 0, len(candidates))
for _, cand := range candidates {
var dist *float64
if user.Latitude != nil && user.Longitude != nil && cand.Latitude != nil && cand.Longitude != nil {
km := math.Round(utils.HaversineKM(*user.Latitude, *user.Longitude, *cand.Latitude, *cand.Longitude))
dist = &km
}
if maxDistanceKM != nil && dist != nil && *dist > *maxDistanceKM {
continue
}
withDist = append(withDist, candidateWithDistance{user: cand, distance: dist})
}
sort.SliceStable(withDist, func(i, j int) bool {
if withDist[i].distance == nil {
return false
}
if withDist[j].distance == nil {
return true
}
return *withDist[i].distance < *withDist[j].distance
})
if len(withDist) > DiscoverResultSize {
withDist = withDist[:DiscoverResultSize]
}
result := make([]dto.DiscoverCandidate, 0, len(withDist))
for _, wd := range withDist {
result = append(result, dto.DiscoverCandidate{
ID: wd.user.ID,
FirstName: wd.user.FirstName,
Age: utils.CalcAge(wd.user.BirthDate),
Photos: photosByUser[wd.user.ID],
DistanceKM: wd.distance,
Interests: interestsByUser[wd.user.ID],
})
}
return result, nil
}
func (s *DiscoveryService) batchInterests(userIDs []uint) map[uint][]string {
result := map[uint][]string{}
cat, err := s.optionRepo.GetCategoryByKey(models.CategoryInterest)
if err != nil {
return result
}
for _, id := range userIDs {
sels, err := s.optionRepo.GetUserSelections(id, cat.ID)
if err != nil {
continue
}
for _, sel := range sels {
if sel.Option != nil {
result[id] = append(result[id], sel.Option.Title)
}
}
}
return result
}
// Swipe یک تصمیم (لایک/ردّ/سوپرلایک) ثبت می‌کنه و در صورت لایک متقابل، مچ می‌سازه.
func (s *DiscoveryService) Swipe(user *models.User, targetID uint, action models.SwipeAction) (*dto.SwipeResponse, error) {
if targetID == user.ID {
return nil, ErrCannotSwipeSelf
}
target, err := s.userRepo.FindByID(targetID)
if err != nil {
return nil, err
}
alreadySwiped, err := s.swipeRepo.Exists(user.ID, targetID)
if err != nil {
return nil, err
}
if alreadySwiped {
return nil, ErrAlreadySwiped
}
if err := s.swipeRepo.Create(&models.Swipe{SwiperID: user.ID, SwipedID: targetID, Action: action}); err != nil {
return nil, err
}
if action == models.SwipePass {
return &dto.SwipeResponse{Matched: false}, nil
}
theyLikedMe, err := s.swipeRepo.HasLiked(targetID, user.ID)
if err != nil {
return nil, err
}
if !theyLikedMe {
return &dto.SwipeResponse{Matched: false}, nil
}
match, _, err := s.matchRepo.FindOrCreate(user.ID, targetID)
if err != nil {
return nil, err
}
photos, _ := s.photoRepo.ListByUser(target.ID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
return &dto.SwipeResponse{
Matched: true,
Match: &dto.MatchView{
ID: match.ID,
UserID: target.ID,
FirstName: target.FirstName,
Photos: urls,
CreatedAt: match.CreatedAt.Format(time.RFC3339),
},
}, nil
}
// GetCandidateDetail پروفایل کامل یک کاربر دیگه رو برای نمایش صفحه‌ی جزئیات برمی‌گردونه
func (s *DiscoveryService) GetCandidateDetail(viewer *models.User, targetID uint) (*dto.CandidateDetail, error) {
target, err := s.userRepo.FindByID(targetID)
if err != nil {
return nil, err
}
if !target.IsOnboarded || target.ApprovalStatus != models.ApprovalApproved || !target.IsActive {
return nil, gorm.ErrRecordNotFound
}
photos, _ := s.photoRepo.ListByUser(target.ID)
urls := make([]string, len(photos))
for i, p := range photos {
urls[i] = p.URL
}
var distance *float64
if viewer.Latitude != nil && viewer.Longitude != nil && target.Latitude != nil && target.Longitude != nil {
km := math.Round(utils.HaversineKM(*viewer.Latitude, *viewer.Longitude, *target.Latitude, *target.Longitude))
distance = &km
}
detail := &dto.CandidateDetail{
ID: target.ID,
FirstName: target.FirstName,
Age: utils.CalcAge(target.BirthDate),
Photos: urls,
DistanceKM: distance,
HeightCM: target.HeightCM,
WeightKG: target.WeightKG,
}
detail.Education = s.firstSelectionTitle(target.ID, models.CategoryEducationLevel)
detail.FieldOfStudy = s.firstSelectionTitle(target.ID, models.CategoryFieldOfStudy)
detail.LookingFor = s.firstSelectionTitle(target.ID, models.CategoryLookingFor)
detail.Occupations = s.allSelectionTitles(target.ID, models.CategoryOccupation)
detail.Interests = s.allSelectionTitles(target.ID, models.CategoryInterest)
detail.ImportantInRelationship = s.allSelectionTitles(target.ID, models.CategoryImportantInRelationship)
if answers, err := s.questionRepo.GetUserAnswers(target.ID); err == nil {
for _, a := range answers {
if a.Question == nil {
continue
}
detail.Answers = append(detail.Answers, dto.CandidateAnswer{
Question: a.Question.Text,
Answer: a.Answer,
})
}
}
return detail, nil
}
func (s *DiscoveryService) allSelectionTitles(userID uint, key models.CategoryKey) []string {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return nil
}
sels, err := s.optionRepo.GetUserSelections(userID, cat.ID)
if err != nil {
return nil
}
titles := make([]string, 0, len(sels))
for _, sel := range sels {
if sel.Option != nil {
titles = append(titles, sel.Option.Title)
}
}
return titles
}
func (s *DiscoveryService) firstSelectionTitle(userID uint, key models.CategoryKey) string {
titles := s.allSelectionTitles(userID, key)
if len(titles) == 0 {
return ""
}
return titles[0]
}
+306
View File
@@ -0,0 +1,306 @@
package service
import (
"errors"
"fmt"
"time"
"dating-app/internal/dto"
"dating-app/internal/models"
"dating-app/internal/repository"
)
var (
ErrStepOutOfOrder = errors.New("لطفاً ابتدا مراحل قبلی را تکمیل کنید")
ErrInvalidBirthDate = errors.New("فرمت تاریخ تولد نامعتبر است (YYYY-MM-DD)")
ErrOptionsNotFound = errors.New("برخی از گزینه‌های انتخابی معتبر نیستند")
ErrSelectionCount = errors.New("تعداد گزینه‌های انتخابی مجاز نیست")
ErrQuestionsInvalid = errors.New("سوالات انتخابی معتبر نیستند")
ErrDuplicateQuestion = errors.New("هر سوال فقط یک‌بار می‌تواند انتخاب شود")
)
type OnboardingService struct {
userRepo *repository.UserRepository
optionRepo *repository.OptionRepository
questionRepo *repository.QuestionRepository
}
func NewOnboardingService(
userRepo *repository.UserRepository,
optionRepo *repository.OptionRepository,
questionRepo *repository.QuestionRepository,
) *OnboardingService {
return &OnboardingService{
userRepo: userRepo,
optionRepo: optionRepo,
questionRepo: questionRepo,
}
}
// canModifyStep بررسی می‌کند که آیا کاربر مجاز به تکمیل/ویرایش این مرحله هست یا نه.
// اگر کاربر قبلاً کل آنبوردینگ رو تموم کرده، اجازه‌ی ویرایش هر مرحله‌ای رو داره.
// در غیر این صورت فقط می‌تونه مرحله‌ی فعلی یا مراحل قبلی (که قبلاً تکمیل شدن) رو بزنه؛
// نمی‌تونه از روی یک مرحله بپره جلو.
func (s *OnboardingService) canModifyStep(user *models.User, step models.OnboardingStep) error {
if user.IsOnboarded {
return nil
}
if step > user.CurrentStep {
return ErrStepOutOfOrder
}
return nil
}
// advanceIfCurrent اگه مرحله‌ی تکمیل‌شده همون مرحله‌ی فعلی کاربر بود، به مرحله‌ی بعد می‌ره
func (s *OnboardingService) advanceIfCurrent(user *models.User, completedStep models.OnboardingStep) error {
if user.CurrentStep != completedStep {
// یا مرحله‌ی قبلی داشت ویرایش می‌شد، یا از قبل onboarded بود.
// اگر قبلاً توسط ادمین رد شده بود، ویرایش پروفایل یعنی درخواست بررسی مجدد.
return s.resubmitIfRejected(user)
}
next := completedStep + 1
updates := map[string]interface{}{"current_step": next}
if next >= models.StepCompleted {
updates["current_step"] = models.StepCompleted
updates["is_onboarded"] = true
updates["approval_status"] = models.ApprovalPending
updates["rejection_reason"] = nil
}
return s.userRepo.UpdateFields(user, updates)
}
// resubmitIfRejected اگه کاربر قبلاً رد شده و بعد از onboarded شدن پروفایلش رو
// ویرایش کنه، وضعیت رو به pending برمی‌گردونه تا ادمین دوباره بررسی کنه.
func (s *OnboardingService) resubmitIfRejected(user *models.User) error {
if !user.IsOnboarded || user.ApprovalStatus != models.ApprovalRejected {
return nil
}
updates := map[string]interface{}{
"approval_status": models.ApprovalPending,
"rejection_reason": nil,
}
if err := s.userRepo.UpdateFields(user, updates); err != nil {
return err
}
user.ApprovalStatus = models.ApprovalPending
user.RejectionReason = nil
return nil
}
// ---------------- مرحله ۱: اطلاعات پایه ----------------
func (s *OnboardingService) SetBasicInfo(user *models.User, req dto.BasicInfoRequest) error {
if err := s.canModifyStep(user, models.StepBasicInfo); err != nil {
return err
}
birthDate, err := time.Parse("2006-01-02", req.BirthDate)
if err != nil {
return ErrInvalidBirthDate
}
gender := models.Gender(req.Gender)
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"gender": gender,
"first_name": req.FirstName,
"birth_date": birthDate,
}); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepBasicInfo)
}
// ---------------- مرحله ۲: قد و وزن ----------------
func (s *OnboardingService) SetBodyInfo(user *models.User, req dto.BodyInfoRequest) error {
if err := s.canModifyStep(user, models.StepBodyInfo); err != nil {
return err
}
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"height_cm": req.HeightCM,
"weight_kg": req.WeightKG,
}); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepBodyInfo)
}
// ---------------- مرحله ۳: سوالات پروفایل (دقیقاً ۳ سوال) ----------------
func (s *OnboardingService) GetAvailableQuestions() ([]models.ProfileQuestion, error) {
return s.questionRepo.ListActive()
}
func (s *OnboardingService) SetProfileQuestions(user *models.User, req dto.ProfileQuestionsRequest) error {
if err := s.canModifyStep(user, models.StepProfileQuestions); err != nil {
return err
}
ids := make([]uint, 0, len(req.Answers))
seen := map[uint]bool{}
for _, a := range req.Answers {
if seen[a.QuestionID] {
return ErrDuplicateQuestion
}
seen[a.QuestionID] = true
ids = append(ids, a.QuestionID)
}
questions, err := s.questionRepo.FindActiveByIDs(ids)
if err != nil {
return err
}
if len(questions) != len(ids) {
return ErrQuestionsInvalid
}
answers := make([]models.UserProfileAnswer, 0, len(req.Answers))
for _, a := range req.Answers {
answers = append(answers, models.UserProfileAnswer{
QuestionID: a.QuestionID,
Answer: a.Answer,
})
}
if err := s.questionRepo.ReplaceUserAnswers(user.ID, answers); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepProfileQuestions)
}
// ---------------- مرحله ۴: تحصیلات (سطح تحصیلات + رشته) ----------------
func (s *OnboardingService) SetEducation(user *models.User, req dto.EducationRequest) error {
if err := s.canModifyStep(user, models.StepEducation); err != nil {
return err
}
if err := s.selectSingleOption(user.ID, models.CategoryEducationLevel, req.EducationLevelOptionID); err != nil {
return err
}
if err := s.selectSingleOption(user.ID, models.CategoryFieldOfStudy, req.FieldOfStudyOptionID); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepEducation)
}
func (s *OnboardingService) selectSingleOption(userID uint, key models.CategoryKey, optionID uint) error {
return s.selectOptions(userID, key, []uint{optionID})
}
// ---------------- مراحل مبتنی بر لیست چندانتخابی عمومی ----------------
// SetOccupations مرحله ۵: کارهایی که کاربر انجام می‌دهد (چندانتخابی، بدون تعداد ثابت)
func (s *OnboardingService) SetOccupations(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepOccupations); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryOccupation, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepOccupations)
}
// SetRelationshipImportance مرحله ۶: دقیقاً ۳ مورد از چیزهای مهم در رابطه
func (s *OnboardingService) SetRelationshipImportance(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepRelationshipImportance); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryImportantInRelationship, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepRelationshipImportance)
}
// SetInterests مرحله ۷: دقیقاً ۵ علاقه‌مندی
func (s *OnboardingService) SetInterests(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepInterests); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryInterest, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepInterests)
}
// SetLookingFor مرحله ۸: دنبال چی می‌گردی
func (s *OnboardingService) SetLookingFor(user *models.User, optionIDs []uint) error {
if err := s.canModifyStep(user, models.StepLookingFor); err != nil {
return err
}
if err := s.selectOptions(user.ID, models.CategoryLookingFor, optionIDs); err != nil {
return err
}
return s.advanceIfCurrent(user, models.StepLookingFor)
}
// selectOptions منطق مشترک اعتبارسنجی + ذخیره‌ی انتخاب‌های یک دسته
func (s *OnboardingService) selectOptions(userID uint, key models.CategoryKey, optionIDs []uint) error {
cat, err := s.optionRepo.GetCategoryByKey(key)
if err != nil {
return err
}
count := len(optionIDs)
if count < cat.MinSelect {
return fmt.Errorf("%w: حداقل باید %d مورد انتخاب کنید", ErrSelectionCount, cat.MinSelect)
}
if cat.MaxSelect > 0 && count > cat.MaxSelect {
return fmt.Errorf("%w: حداکثر می‌توانید %d مورد انتخاب کنید", ErrSelectionCount, cat.MaxSelect)
}
// جلوگیری از تکراری بودن id ها
uniqueIDs := make([]uint, 0, len(optionIDs))
seen := map[uint]bool{}
for _, id := range optionIDs {
if !seen[id] {
seen[id] = true
uniqueIDs = append(uniqueIDs, id)
}
}
validOptions, err := s.optionRepo.FindActiveOptionsByIDsInCategory(cat.ID, uniqueIDs)
if err != nil {
return err
}
if len(validOptions) != len(uniqueIDs) {
return ErrOptionsNotFound
}
return s.optionRepo.ReplaceUserSelections(userID, cat.ID, uniqueIDs)
}
// GetActiveOptions لیست گزینه‌های فعال یک دسته را برمی‌گرداند (برای نمایش به کاربر در هر مرحله)
func (s *OnboardingService) GetActiveOptions(key models.CategoryKey) ([]models.Option, error) {
return s.optionRepo.ListActiveOptionsByCategoryKey(key)
}
// ---------------- وضعیت کلی آنبوردینگ ----------------
func (s *OnboardingService) GetStatus(user *models.User) dto.OnboardingStatusResponse {
steps := make([]dto.StepView, 0, len(models.OnboardingStepOrder))
for _, s2 := range models.OnboardingStepOrder {
done := user.IsOnboarded || user.CurrentStep > s2.Step
steps = append(steps, dto.StepView{
Step: int(s2.Step),
Key: s2.Key,
Done: done,
})
}
currentKey := "completed"
for _, s2 := range models.OnboardingStepOrder {
if s2.Step == user.CurrentStep {
currentKey = s2.Key
break
}
}
return dto.OnboardingStatusResponse{
CurrentStep: int(user.CurrentStep),
CurrentStepKey: currentKey,
IsOnboarded: user.IsOnboarded,
ApprovalStatus: string(user.ApprovalStatus),
RejectionReason: user.RejectionReason,
Steps: steps,
}
}
+41
View File
@@ -0,0 +1,41 @@
package service
import "log"
// OTPSender اینترفیس ارسال پیامک کد تایید.
// برای اتصال به یک گیت‌وی واقعی (کاوه‌نگار، ملی‌پیامک و ...) کافیه یک
// پیاده‌سازی جدید از این اینترفیس بسازی و در main.go جایگزین mock کنی؛
// بقیه‌ی کد (UserService و...) نیازی به تغییر ندارد.
//
// نکته‌ی مهم: پیاده‌سازی واقعی باید SYNC باشه (منتظر پاسخ گیت‌وی بمونه)
// و نتیجه‌ی واقعی ارسال رو برگردونه؛ اگه async/بی‌خیال از نتیجه باشه،
// کاربر حتی در صورت شکست واقعی ارسال، پیام «با موفقیت ارسال شد» می‌گیره
// و منتظر کدی می‌مونه که هیچ‌وقت نمی‌رسه.
type OTPSender interface {
// Send کد OTP را برای شماره‌ی داده‌شده ارسال می‌کند.
// خروجی true یعنی گیت‌وی ارسال را با موفقیت تایید کرده است.
Send(phone string, otp string) bool
}
// MockOTPSender پیاده‌سازی توسعه/تست: به‌جای ارسال واقعی پیامک،
// کد را در لاگ سرور چاپ می‌کند. برای پروداکشن باید با یک گیت‌وی واقعی
// جایگزین شود.
type MockOTPSender struct {
// Debug اگر true باشد، کد OTP در لاگ چاپ می‌شود (فقط برای dev/staging).
Debug bool
}
func NewMockOTPSender(debug bool) *MockOTPSender {
return &MockOTPSender{Debug: debug}
}
func (s *MockOTPSender) Send(phone string, otp string) bool {
if s.Debug {
log.Printf("[MOCK-SMS] کد OTP برای %s: %s", phone, otp)
} else {
log.Printf("[MOCK-SMS] یک کد OTP برای %s ارسال شد", phone)
}
// mock همیشه موفق فرض می‌شود؛ برای شبیه‌سازی خطای گیت‌وی می‌تونی
// این مقدار رو شرطی کنی (مثلاً بر اساس یک فلگ تستی).
return true
}
+61
View File
@@ -0,0 +1,61 @@
package service
import (
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
)
// PhotoStorage اینترفیس ذخیره‌سازی فایل عکس پروفایل.
// برای اتصال به یک storage واقعی (S3، Arvan Object Storage و...) کافیه یک
// پیاده‌سازی جدید از این اینترفیس بسازی و در main.go جایگزین نسخه‌ی local کنی؛
// بقیه‌ی کد (ProfileService و...) نیازی به تغییر ندارد.
type PhotoStorage interface {
// Save فایل رو ذخیره می‌کنه و URL قابل‌دسترسی (نسبی یا کامل) رو برمی‌گردونه.
Save(userID uint, ext string, data []byte) (url string, err error)
// Delete فایل رو با URL برگردونده‌شده از Save حذف می‌کنه.
Delete(url string) error
}
// LocalPhotoStorage فایل‌ها رو روی دیسک محلی سرور ذخیره می‌کنه و از طریق
// مسیر استاتیک /uploads سرو می‌شن. برای پروداکشن با تعداد کاربر بالا،
// بهتره با یک object storage واقعی جایگزین بشه (فایل‌ها روی دیسک سرور API
// می‌مونن و بین چند instance به اشتراک گذاشته نمی‌شن).
type LocalPhotoStorage struct {
rootDir string // مسیر روی دیسک، مثلاً "./uploads/photos"
urlPrefix string // پیشوند URL عمومی، مثلاً "/uploads/photos"
}
func NewLocalPhotoStorage(rootDir, urlPrefix string) *LocalPhotoStorage {
return &LocalPhotoStorage{rootDir: rootDir, urlPrefix: urlPrefix}
}
func (s *LocalPhotoStorage) Save(userID uint, ext string, data []byte) (string, error) {
userDir := filepath.Join(s.rootDir, fmt.Sprintf("%d", userID))
if err := os.MkdirAll(userDir, 0o755); err != nil {
return "", err
}
filename := uuid.NewString() + ext
fullPath := filepath.Join(userDir, filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return "", err
}
return fmt.Sprintf("%s/%d/%s", s.urlPrefix, userID, filename), nil
}
func (s *LocalPhotoStorage) Delete(url string) error {
rel, err := filepath.Rel(s.urlPrefix, url)
if err != nil {
return err
}
fullPath := filepath.Join(s.rootDir, rel)
err = os.Remove(fullPath)
if os.IsNotExist(err) {
return nil
}
return err
}
+86
View File
@@ -0,0 +1,86 @@
package service
import (
"errors"
"dating-app/internal/models"
"dating-app/internal/repository"
)
const MaxPhotosPerUser = 6
var (
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
)
const MaxPhotoSizeBytes = 5 * 1024 * 1024
type ProfileService struct {
photoRepo *repository.PhotoRepository
userRepo *repository.UserRepository
storage PhotoStorage
}
func NewProfileService(photoRepo *repository.PhotoRepository, userRepo *repository.UserRepository, storage PhotoStorage) *ProfileService {
return &ProfileService{photoRepo: photoRepo, userRepo: userRepo, storage: storage}
}
var allowedImageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".webp": true,
}
func (s *ProfileService) UploadPhoto(userID uint, ext string, data []byte) (*models.Photo, error) {
if !allowedImageExts[ext] {
return nil, ErrInvalidImage
}
if len(data) > MaxPhotoSizeBytes {
return nil, ErrImageTooLarge
}
count, err := s.photoRepo.CountByUser(userID)
if err != nil {
return nil, err
}
if count >= MaxPhotosPerUser {
return nil, ErrTooManyPhotos
}
url, err := s.storage.Save(userID, ext, data)
if err != nil {
return nil, err
}
photo := &models.Photo{UserID: userID, URL: url, SortOrder: int(count)}
if err := s.photoRepo.Create(photo); err != nil {
return nil, err
}
return photo, nil
}
func (s *ProfileService) DeletePhoto(userID, photoID uint) error {
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return err
}
if photo.UserID != userID {
return ErrPhotoNotOwned
}
if err := s.photoRepo.Delete(photoID); err != nil {
return err
}
return s.storage.Delete(photo.URL)
}
func (s *ProfileService) ListPhotos(userID uint) ([]models.Photo, error) {
return s.photoRepo.ListByUser(userID)
}
func (s *ProfileService) UpdateLocation(user *models.User, lat, lng float64) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{
"latitude": lat,
"longitude": lng,
})
}