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:
@@ -0,0 +1,304 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
adminService *service.AdminService
|
||||
optionRepo *repository.OptionRepository
|
||||
questionRepo *repository.QuestionRepository
|
||||
photoRepo *repository.PhotoRepository
|
||||
onboardingService *service.OnboardingService
|
||||
}
|
||||
|
||||
func NewAdminHandler(
|
||||
adminService *service.AdminService,
|
||||
optionRepo *repository.OptionRepository,
|
||||
questionRepo *repository.QuestionRepository,
|
||||
photoRepo *repository.PhotoRepository,
|
||||
onboardingService *service.OnboardingService,
|
||||
) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
adminService: adminService,
|
||||
optionRepo: optionRepo,
|
||||
questionRepo: questionRepo,
|
||||
photoRepo: photoRepo,
|
||||
onboardingService: onboardingService,
|
||||
}
|
||||
}
|
||||
|
||||
func validCategoryKey(key string) (models.CategoryKey, bool) {
|
||||
k := models.CategoryKey(key)
|
||||
for _, valid := range models.AllCategoryKeys {
|
||||
if valid == k {
|
||||
return k, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ---------------- دستهها ----------------
|
||||
|
||||
// GetCategory GET /admin/categories/:key
|
||||
func (h *AdminHandler) GetCategory(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
cat, err := h.adminService.GetCategory(key)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "دسته یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", cat)
|
||||
}
|
||||
|
||||
// UpdateCategory PATCH /admin/categories/:key
|
||||
func (h *AdminHandler) UpdateCategory(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.UpdateCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
cat, err := h.adminService.UpdateCategory(key, req)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "دسته بهروزرسانی شد", cat)
|
||||
}
|
||||
|
||||
// ---------------- گزینهها ----------------
|
||||
|
||||
// ListOptions GET /admin/categories/:key/options
|
||||
func (h *AdminHandler) ListOptions(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
options, err := h.adminService.ListOptions(key)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینهها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", options)
|
||||
}
|
||||
|
||||
// CreateOption POST /admin/categories/:key/options
|
||||
func (h *AdminHandler) CreateOption(c *gin.Context) {
|
||||
key, ok := validCategoryKey(c.Param("key"))
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.CreateOptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
opt, err := h.adminService.CreateOption(key, req)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "گزینه ساخته شد", opt)
|
||||
}
|
||||
|
||||
// UpdateOption PATCH /admin/options/:id
|
||||
func (h *AdminHandler) UpdateOption(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.UpdateOptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
opt, err := h.adminService.UpdateOption(uint(id), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, http.StatusNotFound, "گزینه یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "گزینه بهروزرسانی شد", opt)
|
||||
}
|
||||
|
||||
// DeleteOption DELETE /admin/options/:id
|
||||
func (h *AdminHandler) DeleteOption(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.DeleteOption(uint(id)); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "حذف گزینه با خطا مواجه شد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "گزینه حذف شد", nil)
|
||||
}
|
||||
|
||||
// ---------------- بررسی و تایید کاربران ----------------
|
||||
|
||||
// ListPendingUsers GET /admin/users/pending
|
||||
func (h *AdminHandler) ListPendingUsers(c *gin.Context) {
|
||||
users, err := h.adminService.ListPendingUsers()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
|
||||
return
|
||||
}
|
||||
result := make([]dto.UserProfile, 0, len(users))
|
||||
for i := range users {
|
||||
result = append(result, toUserProfile(&users[i]))
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", result)
|
||||
}
|
||||
|
||||
// GetUserDetail GET /admin/users/:id — پروفایل کامل یک کاربر برای بررسی ادمین
|
||||
func (h *AdminHandler) GetUserDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.GetUser(uint(id))
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo))
|
||||
}
|
||||
|
||||
// ApproveUser POST /admin/users/:id/approve
|
||||
func (h *AdminHandler) ApproveUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.ApproveUser(uint(id))
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کاربر تایید شد", toUserProfile(user))
|
||||
}
|
||||
|
||||
// RejectUser POST /admin/users/:id/reject body: {reason}
|
||||
func (h *AdminHandler) RejectUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.RejectUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل رد الزامی است)", err.Error())
|
||||
return
|
||||
}
|
||||
user, err := h.adminService.RejectUser(uint(id), req.Reason)
|
||||
if err != nil {
|
||||
h.handleUserReviewError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کاربر رد شد", toUserProfile(user))
|
||||
}
|
||||
|
||||
func (h *AdminHandler) handleUserReviewError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
utils.Fail(c, http.StatusNotFound, "کاربر یافت نشد", nil)
|
||||
case errors.Is(err, service.ErrUserNotOnboarded):
|
||||
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- سوالات پروفایل ----------------
|
||||
|
||||
// ListQuestions GET /admin/profile-questions
|
||||
func (h *AdminHandler) ListQuestions(c *gin.Context) {
|
||||
qs, err := h.adminService.ListQuestions()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", qs)
|
||||
}
|
||||
|
||||
// CreateQuestion POST /admin/profile-questions
|
||||
func (h *AdminHandler) CreateQuestion(c *gin.Context) {
|
||||
var req dto.CreateProfileQuestionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
q, err := h.adminService.CreateQuestion(req)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "سوال ساخته شد", q)
|
||||
}
|
||||
|
||||
// UpdateQuestion PATCH /admin/profile-questions/:id
|
||||
func (h *AdminHandler) UpdateQuestion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.UpdateProfileQuestionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
q, err := h.adminService.UpdateQuestion(uint(id), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
utils.Fail(c, http.StatusNotFound, "سوال یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "سوال بهروزرسانی شد", q)
|
||||
}
|
||||
|
||||
// DeleteQuestion DELETE /admin/profile-questions/:id
|
||||
func (h *AdminHandler) DeleteQuestion(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.DeleteQuestion(uint(id)); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "حذف سوال با خطا مواجه شد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "سوال حذف شد", nil)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{authService: authService}
|
||||
}
|
||||
|
||||
// RequestOTP POST /auth/request-otp
|
||||
func (h *AuthHandler) RequestOTP(c *gin.Context) {
|
||||
var req dto.RequestOTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.RequestOTP(req.Phone)
|
||||
if err != nil {
|
||||
var stillValid *service.OTPStillValidError
|
||||
switch {
|
||||
case errors.As(err, &stillValid):
|
||||
utils.Fail(c, http.StatusTooManyRequests,
|
||||
"کد تایید قبلی هنوز معتبر است", gin.H{"remaining_seconds": stillValid.RemainingSeconds})
|
||||
case errors.Is(err, service.ErrPhoneBlocked):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrSMSSendFailed):
|
||||
utils.Fail(c, http.StatusServiceUnavailable, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "کد تایید با موفقیت ارسال شد", nil)
|
||||
}
|
||||
|
||||
// VerifyOTP POST /auth/verify-otp
|
||||
func (h *AuthHandler) VerifyOTP(c *gin.Context) {
|
||||
var req dto.VerifyOTPRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, access, refresh, isNew, err := h.authService.VerifyOTP(req.Phone, req.OTP)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrUserNotFound):
|
||||
utils.Fail(c, http.StatusNotFound, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrOTPExpired), errors.Is(err, service.ErrOTPInvalid), errors.Is(err, service.ErrOTPNotRequested):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "ورود با موفقیت انجام شد", dto.AuthResponse{
|
||||
Tokens: dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
},
|
||||
User: toUserProfile(user),
|
||||
IsNewUser: isNew,
|
||||
OnboardingDone: user.IsOnboarded,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshToken POST /auth/token/refresh
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req dto.RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
access, refresh, err := h.authService.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusUnauthorized, "رفرش توکن نامعتبر یا منقضی شده است", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Success(c, http.StatusOK, "توکن جدید صادر شد", dto.TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type ChatHandler struct {
|
||||
chatService *service.ChatService
|
||||
}
|
||||
|
||||
func NewChatHandler(chatService *service.ChatService) *ChatHandler {
|
||||
return &ChatHandler{chatService: chatService}
|
||||
}
|
||||
|
||||
// ListMatches GET /matches
|
||||
func (h *ChatHandler) ListMatches(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
matches, err := h.chatService.ListMatches(user.ID)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت مچها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", matches)
|
||||
}
|
||||
|
||||
// ListMessages GET /matches/:id/messages
|
||||
func (h *ChatHandler) ListMessages(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
messages, err := h.chatService.ListMessages(user.ID, uint(matchID))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", messages)
|
||||
}
|
||||
|
||||
// SendMessage POST /matches/:id/messages body: {text}
|
||||
func (h *ChatHandler) SendMessage(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
matchID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.SendMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "متن پیام نمیتونه خالی باشه", err.Error())
|
||||
return
|
||||
}
|
||||
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.Text)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "", msg)
|
||||
}
|
||||
|
||||
func (h *ChatHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrNotMatchMember):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/middleware"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
// currentUser کاربر جاری رو از context (که توسط AuthRequired ست شده) استخراج میکنه
|
||||
func currentUser(c *gin.Context) (*models.User, bool) {
|
||||
val, exists := c.Get(middleware.ContextUserKey)
|
||||
if !exists {
|
||||
utils.Fail(c, http.StatusUnauthorized, "احراز هویت لازم است", nil)
|
||||
return nil, false
|
||||
}
|
||||
user, ok := val.(*models.User)
|
||||
if !ok {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
func toUserProfile(u *models.User) dto.UserProfile {
|
||||
profile := dto.UserProfile{
|
||||
ID: u.ID,
|
||||
Phone: u.Phone,
|
||||
FirstName: u.FirstName,
|
||||
CurrentStep: int(u.CurrentStep),
|
||||
IsOnboarded: u.IsOnboarded,
|
||||
ApprovalStatus: string(u.ApprovalStatus),
|
||||
RejectionReason: u.RejectionReason,
|
||||
IsAdmin: u.IsAdmin,
|
||||
HeightCM: u.HeightCM,
|
||||
WeightKG: u.WeightKG,
|
||||
}
|
||||
if u.Gender != nil {
|
||||
g := string(*u.Gender)
|
||||
profile.Gender = &g
|
||||
}
|
||||
if u.BirthDate != nil {
|
||||
bd := u.BirthDate.Format("2006-01-02")
|
||||
profile.BirthDate = &bd
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
// selectionSummary یک دستهی انتخابی همراه با عنوان گزینههای انتخابشده
|
||||
type selectionSummary struct {
|
||||
CategoryKey string `json:"category_key"`
|
||||
OptionIDs []uint `json:"option_ids"`
|
||||
Titles []string `json:"titles"`
|
||||
}
|
||||
|
||||
// fullProfilePayload پروفایل کامل یک کاربر (اطلاعات پایه + همهی انتخابها/پاسخها)
|
||||
// رو میسازه. هم توسط خود کاربر (GET /profile/me) و هم توسط ادمین موقع بررسی
|
||||
// درخواست تایید (GET /admin/users/:id) استفاده میشه.
|
||||
func fullProfilePayload(
|
||||
user *models.User,
|
||||
optionRepo *repository.OptionRepository,
|
||||
questionRepo *repository.QuestionRepository,
|
||||
onboardingService *service.OnboardingService,
|
||||
photoRepo *repository.PhotoRepository,
|
||||
) gin.H {
|
||||
selections := make([]selectionSummary, 0, len(models.AllCategoryKeys))
|
||||
for _, key := range models.AllCategoryKeys {
|
||||
cat, err := optionRepo.GetCategoryByKey(key)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sels, err := optionRepo.GetUserSelections(user.ID, cat.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summary := selectionSummary{CategoryKey: string(key)}
|
||||
for _, s := range sels {
|
||||
summary.OptionIDs = append(summary.OptionIDs, s.OptionID)
|
||||
if s.Option != nil {
|
||||
summary.Titles = append(summary.Titles, s.Option.Title)
|
||||
}
|
||||
}
|
||||
selections = append(selections, summary)
|
||||
}
|
||||
|
||||
answers, _ := questionRepo.GetUserAnswers(user.ID)
|
||||
photos, _ := photoRepo.ListByUser(user.ID)
|
||||
|
||||
return gin.H{
|
||||
"user": toUserProfile(user),
|
||||
"onboarding_status": onboardingService.GetStatus(user),
|
||||
"selections": selections,
|
||||
"profile_answers": answers,
|
||||
"photos": photos,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type DiscoveryHandler struct {
|
||||
discoveryService *service.DiscoveryService
|
||||
}
|
||||
|
||||
func NewDiscoveryHandler(discoveryService *service.DiscoveryService) *DiscoveryHandler {
|
||||
return &DiscoveryHandler{discoveryService: discoveryService}
|
||||
}
|
||||
|
||||
// Feed GET /discover?max_distance_km=&min_age=&max_age=
|
||||
func (h *DiscoveryHandler) Feed(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var maxDistance *float64
|
||||
if raw := c.Query("max_distance_km"); raw != "" {
|
||||
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
maxDistance = &v
|
||||
}
|
||||
}
|
||||
minAge, _ := strconv.Atoi(c.Query("min_age"))
|
||||
maxAge, _ := strconv.Atoi(c.Query("max_age"))
|
||||
|
||||
candidates, err := h.discoveryService.GetFeed(user, maxDistance, minAge, maxAge)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", candidates)
|
||||
}
|
||||
|
||||
// CandidateDetail GET /discover/:id
|
||||
func (h *DiscoveryHandler) CandidateDetail(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
detail, err := h.discoveryService.GetCandidateDetail(user, uint(targetID))
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusNotFound, "پروفایل یافت نشد", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", detail)
|
||||
}
|
||||
|
||||
// Swipe POST /discover/:id/swipe body: {action: "like"|"pass"|"superlike"}
|
||||
func (h *DiscoveryHandler) Swipe(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
var req dto.SwipeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.discoveryService.Swipe(user, uint(targetID), models.SwipeAction(req.Action))
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", result)
|
||||
}
|
||||
|
||||
func (h *DiscoveryHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrGenderNotSet):
|
||||
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrCannotSwipeSelf), errors.Is(err, service.ErrAlreadySwiped):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطای داخلی سرور", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type OnboardingHandler struct {
|
||||
onboardingService *service.OnboardingService
|
||||
}
|
||||
|
||||
func NewOnboardingHandler(onboardingService *service.OnboardingService) *OnboardingHandler {
|
||||
return &OnboardingHandler{onboardingService: onboardingService}
|
||||
}
|
||||
|
||||
func (h *OnboardingHandler) handleStepError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrStepOutOfOrder):
|
||||
utils.Fail(c, http.StatusConflict, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrInvalidBirthDate),
|
||||
errors.Is(err, service.ErrOptionsNotFound),
|
||||
errors.Is(err, service.ErrQuestionsInvalid),
|
||||
errors.Is(err, service.ErrDuplicateQuestion):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
case errors.Is(err, repository.ErrCategoryNotFound):
|
||||
utils.Fail(c, http.StatusInternalServerError, "دستهی موردنیاز در سیستم تعریف نشده است", nil)
|
||||
case errors.Is(err, service.ErrSelectionCount):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Status GET /onboarding/status
|
||||
func (h *OnboardingHandler) Status(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// BasicInfo POST /onboarding/basic-info
|
||||
func (h *OnboardingHandler) BasicInfo(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.BasicInfoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetBasicInfo(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "اطلاعات پایه ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// BodyInfo POST /onboarding/body-info
|
||||
func (h *OnboardingHandler) BodyInfo(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.BodyInfoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetBodyInfo(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "قد و وزن ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// AvailableQuestions GET /onboarding/profile-questions
|
||||
func (h *OnboardingHandler) AvailableQuestions(c *gin.Context) {
|
||||
questions, err := h.onboardingService.GetAvailableQuestions()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت سوالات", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", questions)
|
||||
}
|
||||
|
||||
// SetProfileQuestions POST /onboarding/profile-questions
|
||||
func (h *OnboardingHandler) SetProfileQuestions(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.ProfileQuestionsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (باید دقیقاً ۳ سوال با پاسخ ارسال شود)", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetProfileQuestions(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "پاسخها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Education POST /onboarding/education
|
||||
func (h *OnboardingHandler) Education(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.EducationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetEducation(user, req); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "اطلاعات تحصیلی ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Occupations POST /onboarding/occupations
|
||||
func (h *OnboardingHandler) Occupations(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetOccupations(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "فعالیتها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// RelationshipImportance POST /onboarding/relationship-importance
|
||||
func (h *OnboardingHandler) RelationshipImportance(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetRelationshipImportance(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "موارد مهم در رابطه ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Interests POST /onboarding/interests
|
||||
func (h *OnboardingHandler) Interests(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetInterests(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "علاقهمندیها ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// LookingFor POST /onboarding/looking-for
|
||||
func (h *OnboardingHandler) LookingFor(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.OptionSelectionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.onboardingService.SetLookingFor(user, req.OptionIDs); err != nil {
|
||||
h.handleStepError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "هدف شما ثبت شد", h.onboardingService.GetStatus(user))
|
||||
}
|
||||
|
||||
// Options GET /onboarding/options/:category
|
||||
// category یکی از: education_level, field_of_study, occupation,
|
||||
// important_in_relationship, interest, looking_for
|
||||
func (h *OnboardingHandler) Options(c *gin.Context) {
|
||||
key := models.CategoryKey(c.Param("category"))
|
||||
valid := false
|
||||
for _, k := range models.AllCategoryKeys {
|
||||
if k == key {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
utils.Fail(c, http.StatusNotFound, "دستهی نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
|
||||
options, err := h.onboardingService.GetActiveOptions(key)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزینهها", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "", options)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/service"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
optionRepo *repository.OptionRepository
|
||||
questionRepo *repository.QuestionRepository
|
||||
photoRepo *repository.PhotoRepository
|
||||
onboardingService *service.OnboardingService
|
||||
profileService *service.ProfileService
|
||||
}
|
||||
|
||||
func NewProfileHandler(
|
||||
optionRepo *repository.OptionRepository,
|
||||
questionRepo *repository.QuestionRepository,
|
||||
photoRepo *repository.PhotoRepository,
|
||||
onboardingService *service.OnboardingService,
|
||||
profileService *service.ProfileService,
|
||||
) *ProfileHandler {
|
||||
return &ProfileHandler{
|
||||
optionRepo: optionRepo,
|
||||
questionRepo: questionRepo,
|
||||
photoRepo: photoRepo,
|
||||
onboardingService: onboardingService,
|
||||
profileService: profileService,
|
||||
}
|
||||
}
|
||||
|
||||
// Me GET /profile/me — پروفایل کامل کاربر شامل تمام انتخابها و پاسخها
|
||||
func (h *ProfileHandler) Me(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
payload := fullProfilePayload(user, h.optionRepo, h.questionRepo, h.onboardingService, h.photoRepo)
|
||||
utils.Success(c, http.StatusOK, "اطلاعات پروفایل با موفقیت دریافت شد", payload)
|
||||
}
|
||||
|
||||
// UploadPhoto POST /profile/photos (multipart/form-data, فیلد "photo")
|
||||
func (h *ProfileHandler) UploadPhoto(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("photo")
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "فایل عکس ارسال نشده", nil)
|
||||
return
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "خطا در خواندن فایل", nil)
|
||||
return
|
||||
}
|
||||
|
||||
photo, err := h.profileService.UploadPhoto(user.ID, ext, data)
|
||||
if err != nil {
|
||||
h.handlePhotoError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusCreated, "عکس اضافه شد", photo)
|
||||
}
|
||||
|
||||
// DeletePhoto DELETE /profile/photos/:id
|
||||
func (h *ProfileHandler) DeletePhoto(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil)
|
||||
return
|
||||
}
|
||||
if err := h.profileService.DeletePhoto(user.ID, uint(id)); err != nil {
|
||||
h.handlePhotoError(c, err)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) handlePhotoError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil)
|
||||
case errors.Is(err, service.ErrPhotoNotOwned):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
case errors.Is(err, service.ErrTooManyPhotos),
|
||||
errors.Is(err, service.ErrInvalidImage),
|
||||
errors.Is(err, service.ErrImageTooLarge):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی عکس", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLocation PATCH /profile/location
|
||||
func (h *ProfileHandler) UpdateLocation(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.UpdateLocationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.profileService.UpdateLocation(user, req.Latitude, req.Longitude); err != nil {
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی موقعیت مکانی", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "موقعیت مکانی ثبت شد", nil)
|
||||
}
|
||||
Reference in New Issue
Block a user