Add notification settings, account deletion, photo reporting, and pending-user photos

Addresses user bug reports: settings pages needed real notification
preferences and account deletion, admin needed a way to flag a specific
photo independent of deleting it, and the pending-approval users list
needed to surface applicant photos for review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 18:08:57 +03:30
parent c8e6f80c51
commit 2c6d4bfbd2
12 changed files with 188 additions and 17 deletions
+10
View File
@@ -51,4 +51,14 @@ type UserProfile struct {
BlockedAt *time.Time `json:"blocked_at,omitempty"`
Bio *string `json:"bio,omitempty"`
StreakCount int `json:"streak_count"`
NotifyMatches bool `json:"notify_matches"`
NotifyMessages bool `json:"notify_messages"`
NotifyLikes bool `json:"notify_likes"`
}
// UserProfileWithPhotos برای لیست‌های ادمینی که علاوه بر خلاصه‌ی پروفایل، عکس
// کاربر رو هم نشون می‌دن (مثلاً کاربران در انتظار تایید).
type UserProfileWithPhotos struct {
UserProfile
Photos []string `json:"photos"`
}
+7
View File
@@ -16,3 +16,10 @@ type CheckInResult struct {
StreakCount int `json:"streak_count"`
IsNewToday bool `json:"is_new_today"`
}
// UpdateNotificationSettingsRequest ترجیحات اعلان کاربر
type UpdateNotificationSettingsRequest struct {
NotifyMatches bool `json:"notify_matches"`
NotifyMessages bool `json:"notify_messages"`
NotifyLikes bool `json:"notify_likes"`
}
+34 -3
View File
@@ -165,14 +165,21 @@ func (h *AdminHandler) DeleteOption(c *gin.Context) {
// ListPendingUsers GET /admin/users/pending
func (h *AdminHandler) ListPendingUsers(c *gin.Context) {
users, err := h.adminService.ListPendingUsers()
users, photosByUser, err := h.adminService.ListPendingUsers()
if err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil)
return
}
result := make([]dto.UserProfile, 0, len(users))
result := make([]dto.UserProfileWithPhotos, 0, len(users))
for i := range users {
result = append(result, toUserProfile(&users[i]))
photos := photosByUser[users[i].ID]
if photos == nil {
photos = []string{}
}
result = append(result, dto.UserProfileWithPhotos{
UserProfile: toUserProfile(&users[i]),
Photos: photos,
})
}
utils.Success(c, http.StatusOK, "", result)
}
@@ -382,6 +389,30 @@ func (h *AdminHandler) DeletePhoto(c *gin.Context) {
utils.Success(c, http.StatusOK, "عکس حذف شد", nil)
}
// ReportPhoto POST /admin/photos/:id/report body: {reason}
func (h *AdminHandler) ReportPhoto(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.ReportUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل گزارش الزامی است)", err.Error())
return
}
report, err := h.adminService.ReportPhoto(uint(id), req.Reason)
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.StatusCreated, "گزارش ثبت شد", report)
}
// ---------------- سوالات پروفایل ----------------
// ListQuestions GET /admin/profile-questions
+3
View File
@@ -45,6 +45,9 @@ func toUserProfile(u *models.User) dto.UserProfile {
BlockedAt: u.BlockedAt,
Bio: u.Bio,
StreakCount: u.StreakCount,
NotifyMatches: u.NotifyMatches,
NotifyMessages: u.NotifyMessages,
NotifyLikes: u.NotifyLikes,
}
if u.Gender != nil {
g := string(*u.Gender)
+31
View File
@@ -170,3 +170,34 @@ func (h *ProfileHandler) CheckIn(c *gin.Context) {
}
utils.Success(c, http.StatusOK, "", result)
}
// UpdateNotificationSettings PATCH /profile/notifications
func (h *ProfileHandler) UpdateNotificationSettings(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
var req dto.UpdateNotificationSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است", err.Error())
return
}
if err := h.profileService.UpdateNotificationSettings(user, req); err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیره‌ی تنظیمات اعلان", nil)
return
}
utils.Success(c, http.StatusOK, "تنظیمات اعلان ذخیره شد", nil)
}
// DeleteAccount DELETE /profile/me — غیرفعال کردن (soft delete) حساب کاربر
func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
user, ok := currentUser(c)
if !ok {
return
}
if err := h.profileService.DeleteAccount(user); err != nil {
utils.Fail(c, http.StatusInternalServerError, "خطا در حذف حساب", nil)
return
}
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
}
+3 -3
View File
@@ -2,12 +2,12 @@ package models
import "time"
// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر ثبت می‌کنه (مثلاً بر
// اساس شکایتی که از کانال دیگه‌ای دریافت شده)؛ برای پیگیری سابقه‌ی نظارتی
// یک کاربر، مستقل از بلاک‌شدن یا نشدنش.
// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر (یا یک عکس مشخص از
// اون کاربر) ثبت می‌کنه؛ برای پیگیری سابقه‌ی نظارتی، مستقل از بلاک/حذف کردن.
type Report struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
PhotoID *uint `json:"photo_id,omitempty"`
Reason string `gorm:"type:text;not null" json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
+7
View File
@@ -97,6 +97,13 @@ type User struct {
StreakCount int `gorm:"default:0;not null" json:"streak_count"`
LastActiveDate *time.Time `json:"last_active_date,omitempty"`
// -------- ترجیحات اعلان --------
// فعلاً فقط ذخیره می‌شن (زیرساخت ارسال واقعی اعلان هنوز پیاده نشده)؛ وقتی
// اون سیستم اضافه شد، همین مقادیر رو برای تصمیم به ارسال یا نه می‌خونه.
NotifyMatches bool `gorm:"default:true;not null" json:"notify_matches"`
NotifyMessages bool `gorm:"default:true;not null" json:"notify_messages"`
NotifyLikes bool `gorm:"default:true;not null" json:"notify_likes"`
// -------- موقعیت مکانی (برای محاسبه‌ی فاصله‌ی تقریبی در دوستیابی) --------
Latitude *float64 `json:"-"`
Longitude *float64 `json:"-"`
+3
View File
@@ -61,6 +61,8 @@ func Setup(
profile.PATCH("/bio", h.Profile.UpdateBio)
// استریک روزانه (گیمیفیکیشن): هر بار اپ باز می‌شه یه بار صدا زده می‌شه
profile.POST("/checkin", h.Profile.CheckIn)
profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
profile.DELETE("/me", h.Profile.DeleteAccount)
}
// -------- آنبوردینگ (نیاز به ورود) --------
@@ -115,6 +117,7 @@ func Setup(
// نظارت بر عکس‌های آپلودشده
admin.GET("/photos", h.Admin.ListAllPhotos)
admin.DELETE("/photos/:id", h.Admin.DeletePhoto)
admin.POST("/photos/:id/report", h.Admin.ReportPhoto)
admin.GET("/categories/:key", h.Admin.GetCategory)
admin.PATCH("/categories/:key", h.Admin.UpdateCategory)
+40 -3
View File
@@ -52,9 +52,29 @@ 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()
// ListPendingUsers کاربرانی که آنبوردینگ رو تکمیل کردن و منتظر بررسی‌اند، به
// همراه عکس‌هاشون (batch گرفته می‌شه، نه یک کوئری جدا به‌ازای هر کاربر) تا
// ادمین بدون کلیک روی هرکدوم بتونه یه نگاه سریع به عکس‌ها بندازه.
func (s *AdminService) ListPendingUsers() ([]models.User, map[uint][]string, error) {
users, err := s.userRepo.ListPendingApproval()
if err != nil {
return nil, nil, err
}
ids := make([]uint, len(users))
for i, u := range users {
ids[i] = u.ID
}
photos, err := s.photoRepo.ListByUserIDs(ids)
if err != nil {
return nil, nil, err
}
photosByUser := make(map[uint][]string, len(ids))
for _, p := range photos {
photosByUser[p.UserID] = append(photosByUser[p.UserID], p.URL)
}
return users, photosByUser, nil
}
// ApproveUser کاربر رو تایید می‌کنه تا بتونه وارد صفحه‌ی دوستیابی بشه
@@ -205,6 +225,23 @@ func (s *AdminService) ReportUser(id uint, reason string) (*models.Report, error
return report, nil
}
// ReportPhoto یک عکس مشخص رو گزارش می‌کنه (روی صاحب عکس هم توی سابقه‌ی
// گزارش‌ها ثبت می‌شه، با ارجاع به همون عکس).
func (s *AdminService) ReportPhoto(photoID uint, reason string) (*models.Report, error) {
if reason == "" {
return nil, ErrReportReasonEmpty
}
photo, err := s.photoRepo.FindByID(photoID)
if err != nil {
return nil, err
}
report := &models.Report{UserID: photo.UserID, PhotoID: &photo.ID, Reason: reason}
if err := s.reportRepo.Create(report); err != nil {
return nil, err
}
return report, nil
}
// ListReports سابقه‌ی گزارش‌های ثبت‌شده روی یک کاربر رو برمی‌گردونه.
func (s *AdminService) ListReports(userID uint) ([]models.Report, error) {
return s.reportRepo.ListByUser(userID)
+22
View File
@@ -141,3 +141,25 @@ func (s *ProfileService) CheckIn(user *models.User) (*dto.CheckInResult, error)
return &dto.CheckInResult{StreakCount: newStreak, IsNewToday: true}, nil
}
// UpdateNotificationSettings ترجیحات اعلان کاربر رو ذخیره می‌کنه.
func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.UpdateNotificationSettingsRequest) error {
if err := s.userRepo.UpdateFields(user, map[string]interface{}{
"notify_matches": req.NotifyMatches,
"notify_messages": req.NotifyMessages,
"notify_likes": req.NotifyLikes,
}); err != nil {
return err
}
user.NotifyMatches = req.NotifyMatches
user.NotifyMessages = req.NotifyMessages
user.NotifyLikes = req.NotifyLikes
return nil
}
// DeleteAccount حساب کاربر رو غیرفعال می‌کنه (soft) نه پاک‌سازی کامل رکورد؛
// چون مچ‌ها/پیام‌های طرف مقابل نباید یهو پاره بشن. کاربر غیرفعال دیگه اصلاً
// نمی‌تونه لاگین کنه (چک IsActive توی AuthRequired و AuthService).
func (s *ProfileService) DeleteAccount(user *models.User) error {
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
}