From 1dc96e86fcff438cca2c5212c2de3d419b180e1a Mon Sep 17 00:00:00 2001 From: mobiniqow Date: Sun, 20 Sep 2026 05:30:14 +0330 Subject: [PATCH] Add admin user moderation: block/unblock, reports, matches list, photo review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User blocking (with reason) now closes the auth gap immediately — a blocked user's still-valid access token stops working on the next request, not just on their next OTP/refresh. - Admin-created reports (moderation notes) per user, viewable in a history list independent of block status. - GET /admin/users: full paginated, searchable user list with report counts. - GET /admin/matches: all matches across the app ("friends" who mutually liked each other), for admin visibility. - GET/DELETE /admin/photos: paginated feed of every uploaded photo with uploader info, so admins can review and remove content without going user-by-user. - Per-user activity: monitoring requests can now be filtered by user_id, so a user's detail page can show their recent API activity. - Batched the profile-selections query in fullProfilePayload (was 6 queries per view, now 1) for consistency with the earlier N+1 fixes. --- cmd/api/main.go | 3 +- internal/database/database.go | 1 + internal/dto/admin_dto.go | 10 + internal/dto/auth_dto.go | 29 +-- internal/handler/admin_handler.go | 144 ++++++++++++++ internal/handler/common.go | 23 ++- internal/handler/monitoring_handler.go | 12 +- internal/middleware/auth_middleware.go | 8 + internal/models/report.go | 17 ++ internal/models/user.go | 4 + internal/monitoring/store.go | 23 +++ internal/monitoring/store_test.go | 19 ++ internal/repository/match_repository.go | 11 ++ internal/repository/photo_repository.go | 12 ++ internal/repository/report_repository.go | 53 +++++ internal/repository/user_repository.go | 19 ++ internal/router/router.go | 12 ++ internal/service/admin_service.go | 240 ++++++++++++++++++++++- internal/service/profile_service.go | 13 ++ 19 files changed, 628 insertions(+), 25 deletions(-) create mode 100644 internal/models/report.go create mode 100644 internal/repository/report_repository.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 06abdd5..007edd6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -49,6 +49,7 @@ func main() { swipeRepo := repository.NewSwipeRepository(db) matchRepo := repository.NewMatchRepository(db) messageRepo := repository.NewMessageRepository(db) + reportRepo := repository.NewReportRepository(db) // -------- Utils / Infra -------- jwtManager := utils.NewJWTManager( @@ -70,8 +71,8 @@ func main() { cfg.OTPExpirySeconds, cfg.OTPLength, cfg.OTPHashSecret, ) onboardingService := service.NewOnboardingService(userRepo, optionRepo, questionRepo, photoRepo) - adminService := service.NewAdminService(optionRepo, questionRepo, userRepo) profileService := service.NewProfileService(photoRepo, userRepo, photoStorage) + adminService := service.NewAdminService(optionRepo, questionRepo, userRepo, reportRepo, matchRepo, photoRepo, profileService) discoveryService := service.NewDiscoveryService(userRepo, photoRepo, swipeRepo, matchRepo, optionRepo, questionRepo) chatService := service.NewChatService(matchRepo, messageRepo, userRepo, photoRepo) backupService := service.NewBackupService(cfg, "./backups") diff --git a/internal/database/database.go b/internal/database/database.go index 1866f54..b641a8b 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB) error { &models.Swipe{}, &models.Match{}, &models.Message{}, + &models.Report{}, ) } diff --git a/internal/dto/admin_dto.go b/internal/dto/admin_dto.go index 623e494..5dd7e36 100644 --- a/internal/dto/admin_dto.go +++ b/internal/dto/admin_dto.go @@ -37,3 +37,13 @@ type UpdateProfileQuestionRequest struct { type RejectUserRequest struct { Reason string `json:"reason" binding:"required,min=3,max=500"` } + +// BlockUserRequest بلاک کردن یک کاربر توسط ادمین (دلیل الزامی است) +type BlockUserRequest struct { + Reason string `json:"reason" binding:"required,min=3,max=500"` +} + +// ReportUserRequest ثبت یک یادداشت نظارتی روی یک کاربر (دلیل الزامی است) +type ReportUserRequest struct { + Reason string `json:"reason" binding:"required,min=3,max=500"` +} diff --git a/internal/dto/auth_dto.go b/internal/dto/auth_dto.go index 6eb7fae..9fb4e01 100644 --- a/internal/dto/auth_dto.go +++ b/internal/dto/auth_dto.go @@ -1,5 +1,7 @@ package dto +import "time" + // RequestOTPRequest بدنه‌ی درخواست ارسال کد تایید type RequestOTPRequest struct { Phone string `json:"phone" binding:"required"` @@ -32,16 +34,19 @@ type AuthResponse struct { // UserProfile نمایش خلاصه‌ی پروفایل کاربر (بدون داده‌های حساس) type UserProfile struct { - ID uint `json:"id"` - Phone string `json:"phone"` - FirstName string `json:"first_name,omitempty"` - Gender *string `json:"gender,omitempty"` - BirthDate *string `json:"birth_date,omitempty"` - HeightCM *int `json:"height_cm,omitempty"` - WeightKG *int `json:"weight_kg,omitempty"` - CurrentStep int `json:"current_step"` - IsOnboarded bool `json:"is_onboarded"` - ApprovalStatus string `json:"approval_status"` - RejectionReason *string `json:"rejection_reason,omitempty"` - IsAdmin bool `json:"is_admin"` + ID uint `json:"id"` + Phone string `json:"phone"` + FirstName string `json:"first_name,omitempty"` + Gender *string `json:"gender,omitempty"` + BirthDate *string `json:"birth_date,omitempty"` + HeightCM *int `json:"height_cm,omitempty"` + WeightKG *int `json:"weight_kg,omitempty"` + CurrentStep int `json:"current_step"` + IsOnboarded bool `json:"is_onboarded"` + ApprovalStatus string `json:"approval_status"` + RejectionReason *string `json:"rejection_reason,omitempty"` + IsAdmin bool `json:"is_admin"` + State string `json:"state"` + BlockedReason *string `json:"blocked_reason,omitempty"` + BlockedAt *time.Time `json:"blocked_at,omitempty"` } diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go index aa1d93c..e2dc643 100644 --- a/internal/handler/admin_handler.go +++ b/internal/handler/admin_handler.go @@ -238,6 +238,150 @@ func (h *AdminHandler) handleUserReviewError(c *gin.Context, err error) { } } +// paginationParams صفحه/تعداد رو از کوئری استرینگ می‌خونه (پیش‌فرض‌های معقول اگه نبود/نامعتبر بود) +func paginationParams(c *gin.Context, defaultSize int) (page, pageSize int) { + page = 1 + if p, err := strconv.Atoi(c.Query("page")); err == nil && p > 0 { + page = p + } + pageSize = defaultSize + if ps, err := strconv.Atoi(c.Query("page_size")); err == nil && ps > 0 { + pageSize = ps + } + return +} + +// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ---------------- + +// ListAllUsers GET /admin/users?search=&page=&page_size= +func (h *AdminHandler) ListAllUsers(c *gin.Context) { + page, pageSize := paginationParams(c, 25) + search := c.Query("search") + + items, total, err := h.adminService.ListAllUsers(search, page, pageSize) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست کاربران", nil) + return + } + utils.Success(c, http.StatusOK, "", gin.H{ + "items": items, "total": total, "page": page, "page_size": pageSize, + }) +} + +// BlockUser POST /admin/users/:id/block body: {reason} +func (h *AdminHandler) BlockUser(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.BlockUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.Fail(c, http.StatusBadRequest, "ورودی نامعتبر است (ذکر دلیل بلاک الزامی است)", err.Error()) + return + } + user, err := h.adminService.BlockUser(uint(id), req.Reason) + if err != nil { + h.handleUserReviewError(c, err) + return + } + utils.Success(c, http.StatusOK, "کاربر بلاک شد", toUserProfile(user)) +} + +// UnblockUser POST /admin/users/:id/unblock +func (h *AdminHandler) UnblockUser(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.UnblockUser(uint(id)) + if err != nil { + h.handleUserReviewError(c, err) + return + } + utils.Success(c, http.StatusOK, "کاربر آنبلاک شد", toUserProfile(user)) +} + +// ReportUser POST /admin/users/:id/report body: {reason} +func (h *AdminHandler) ReportUser(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.ReportUser(uint(id), req.Reason) + if err != nil { + h.handleUserReviewError(c, err) + return + } + utils.Success(c, http.StatusCreated, "گزارش ثبت شد", report) +} + +// ListUserReports GET /admin/users/:id/reports +func (h *AdminHandler) ListUserReports(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + utils.Fail(c, http.StatusBadRequest, "شناسه نامعتبر است", nil) + return + } + reports, err := h.adminService.ListReports(uint(id)) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت گزارش‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", reports) +} + +// ---------------- مچ‌ها (رفقا) ---------------- + +// ListAllMatches GET /admin/matches?page=&page_size= +func (h *AdminHandler) ListAllMatches(c *gin.Context) { + page, pageSize := paginationParams(c, 25) + items, total, err := h.adminService.ListAllMatches(page, pageSize) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت لیست مچ‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", gin.H{ + "items": items, "total": total, "page": page, "page_size": pageSize, + }) +} + +// ---------------- نظارت بر عکس‌ها ---------------- + +// ListAllPhotos GET /admin/photos?page=&page_size= +func (h *AdminHandler) ListAllPhotos(c *gin.Context) { + page, pageSize := paginationParams(c, 50) + items, total, err := h.adminService.ListAllPhotos(page, pageSize) + if err != nil { + utils.Fail(c, http.StatusInternalServerError, "خطا در دریافت عکس‌ها", nil) + return + } + utils.Success(c, http.StatusOK, "", gin.H{ + "items": items, "total": total, "page": page, "page_size": pageSize, + }) +} + +// DeletePhoto DELETE /admin/photos/:id +func (h *AdminHandler) DeletePhoto(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.DeletePhotoAdmin(uint(id)); err != nil { + utils.Fail(c, http.StatusNotFound, "عکس یافت نشد", nil) + return + } + utils.Success(c, http.StatusOK, "عکس حذف شد", nil) +} + // ---------------- سوالات پروفایل ---------------- // ListQuestions GET /admin/profile-questions diff --git a/internal/handler/common.go b/internal/handler/common.go index 432c4a4..e821260 100644 --- a/internal/handler/common.go +++ b/internal/handler/common.go @@ -40,6 +40,9 @@ func toUserProfile(u *models.User) dto.UserProfile { IsAdmin: u.IsAdmin, HeightCM: u.HeightCM, WeightKG: u.WeightKG, + State: string(u.State), + BlockedReason: u.BlockedReason, + BlockedAt: u.BlockedAt, } if u.Gender != nil { g := string(*u.Gender) @@ -69,18 +72,26 @@ func fullProfilePayload( onboardingService *service.OnboardingService, photoRepo *repository.PhotoRepository, ) gin.H { - selections := make([]selectionSummary, 0, len(models.AllCategoryKeys)) + // دسته‌ها از کش خونده می‌شن (تغییر به‌ندرت اتفاق می‌افته)، ولی خودِ + // انتخاب‌های کاربر توی همه‌ی دسته‌ها با یک کوئری واحد گرفته می‌شه؛ قبلاً + // اینجا به‌ازای هر ۶ دسته یک کوئری جدا زده می‌شد. + keyToCategoryID := make(map[models.CategoryKey]uint, len(models.AllCategoryKeys)) + categoryIDs := make([]uint, 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 - } + keyToCategoryID[key] = cat.ID + categoryIDs = append(categoryIDs, cat.ID) + } + + selectionsByCategoryID, _ := optionRepo.GetUserSelectionsForCategories(user.ID, categoryIDs) + + selections := make([]selectionSummary, 0, len(models.AllCategoryKeys)) + for _, key := range models.AllCategoryKeys { summary := selectionSummary{CategoryKey: string(key)} - for _, s := range sels { + for _, s := range selectionsByCategoryID[keyToCategoryID[key]] { summary.OptionIDs = append(summary.OptionIDs, s.OptionID) if s.Option != nil { summary.Titles = append(summary.Titles, s.Option.Title) diff --git a/internal/handler/monitoring_handler.go b/internal/handler/monitoring_handler.go index 7643208..fee10f7 100644 --- a/internal/handler/monitoring_handler.go +++ b/internal/handler/monitoring_handler.go @@ -23,7 +23,9 @@ func (h *MonitoringHandler) Summary(c *gin.Context) { utils.Success(c, http.StatusOK, "", h.store.Summary()) } -// RecentRequests GET /admin/monitoring/requests?limit=100 +// RecentRequests GET /admin/monitoring/requests?limit=100&user_id=42 +// اگه user_id داده بشه، فقط فعالیت اخیر همون کاربر برمی‌گرده (برای صفحه‌ی +// جزئیات کاربر توی پنل ادمین)؛ وگرنه آخرین درخواست‌های کل سرور. func (h *MonitoringHandler) RecentRequests(c *gin.Context) { limit := 100 if raw := c.Query("limit"); raw != "" { @@ -31,5 +33,13 @@ func (h *MonitoringHandler) RecentRequests(c *gin.Context) { limit = n } } + + if raw := c.Query("user_id"); raw != "" { + if uid, err := strconv.ParseUint(raw, 10, 64); err == nil { + utils.Success(c, http.StatusOK, "", h.store.RecentByUser(uint(uid), limit)) + return + } + } + utils.Success(c, http.StatusOK, "", h.store.Recent(limit)) } diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 4d614ef..10cea2c 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "dating-app/internal/models" "dating-app/internal/repository" "dating-app/internal/utils" ) @@ -44,6 +45,13 @@ func AuthRequired(jwtManager *utils.JWTManager, userRepo *repository.UserReposit c.Abort() return } + // اگه کاربر بعد از صادر شدن access token فعلی بلاک شده باشه، نباید تا + // انقضای طبیعی همون توکن بتونه درخواست بزنه؛ این چک همون لحظه قطعش می‌کنه. + if user.State == models.UserStateBlocked { + utils.Fail(c, http.StatusForbidden, "حساب کاربری مسدود شده است", nil) + c.Abort() + return + } c.Set(ContextUserKey, user) c.Next() diff --git a/internal/models/report.go b/internal/models/report.go new file mode 100644 index 0000000..47e97aa --- /dev/null +++ b/internal/models/report.go @@ -0,0 +1,17 @@ +package models + +import "time" + +// Report یک یادداشت/پرچم نظارتی که ادمین روی یک کاربر ثبت می‌کنه (مثلاً بر +// اساس شکایتی که از کانال دیگه‌ای دریافت شده)؛ برای پیگیری سابقه‌ی نظارتی +// یک کاربر، مستقل از بلاک‌شدن یا نشدنش. +type Report struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Reason string `gorm:"type:text;not null" json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +func (Report) TableName() string { + return "reports" +} diff --git a/internal/models/user.go b/internal/models/user.go index 7488849..e9e67c7 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -77,6 +77,10 @@ type User struct { IsAdmin bool `gorm:"default:false;not null" json:"is_admin"` IsActive bool `gorm:"default:true;not null" json:"is_active"` + // -------- بلاک شدن توسط ادمین -------- + BlockedReason *string `json:"blocked_reason,omitempty"` + BlockedAt *time.Time `json:"blocked_at,omitempty"` + // فیلدهای OTP - هش می‌شوند و هرگز در پاسخ API برنمی‌گردند OTPCode *string `gorm:"size:128" json:"-"` OTPExpiry *time.Time `json:"-"` diff --git a/internal/monitoring/store.go b/internal/monitoring/store.go index 7ac5a22..0d99790 100644 --- a/internal/monitoring/store.go +++ b/internal/monitoring/store.go @@ -91,6 +91,29 @@ func (s *Store) Recent(n int) []RequestRecord { return out } +// RecentByUser مثل Recent ولی فقط درخواست‌های یک کاربر مشخص رو برمی‌گردونه +// (برای دیدن فعالیت اخیر یک کاربر خاص از صفحه‌ی جزئیاتش توی پنل ادمین). +// کل بافر رو می‌گرده (نه فقط آخرین n)، چون فیلتر شده و ممکنه درخواست‌های آن +// کاربر پراکنده باشن. +func (s *Store) RecentByUser(userID uint, n int) []RequestRecord { + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]RequestRecord, 0, n) + idx := s.next - 1 + for i := 0; i < s.count && len(out) < n; i++ { + if idx < 0 { + idx = bufferSize - 1 + } + rec := s.buf[idx] + if rec.UserID != nil && *rec.UserID == userID { + out = append(out, rec) + } + idx-- + } + return out +} + func (s *Store) Summary() Summary { s.mu.RLock() defer s.mu.RUnlock() diff --git a/internal/monitoring/store_test.go b/internal/monitoring/store_test.go index 0335f32..0f2f94d 100644 --- a/internal/monitoring/store_test.go +++ b/internal/monitoring/store_test.go @@ -72,3 +72,22 @@ func TestStore_RingBufferWraparound(t *testing.T) { t.Fatalf("expected Recent to be capped at bufferSize (%d), got %d", bufferSize, len(recent)) } } + +func TestStore_RecentByUser(t *testing.T) { + s := NewStore() + uidA := uint(1) + uidB := uint(2) + + s.Record(RequestRecord{Time: time.Now(), Path: "/a1", UserID: &uidA}) + s.Record(RequestRecord{Time: time.Now(), Path: "/b1", UserID: &uidB}) + s.Record(RequestRecord{Time: time.Now(), Path: "/a2", UserID: &uidA}) + s.Record(RequestRecord{Time: time.Now(), Path: "/none"}) // بدون کاربر (مثل OTP) + + got := s.RecentByUser(uidA, 10) + if len(got) != 2 { + t.Fatalf("expected 2 records for user A, got %d", len(got)) + } + if got[0].Path != "/a2" || got[1].Path != "/a1" { + t.Fatalf("expected newest-first order for user A, got %+v", got) + } +} diff --git a/internal/repository/match_repository.go b/internal/repository/match_repository.go index 639d088..1202d97 100644 --- a/internal/repository/match_repository.go +++ b/internal/repository/match_repository.go @@ -63,6 +63,17 @@ func (r *MatchRepository) ListByUser(userID uint) ([]models.Match, error) { return matches, err } +// ListAllPaginated همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه (برای پنل ادمین) +func (r *MatchRepository) ListAllPaginated(limit, offset int) ([]models.Match, int64, error) { + var total int64 + if err := r.db.Model(&models.Match{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var matches []models.Match + err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&matches).Error + return matches, total, err +} + // OtherUserID شناسه‌ی طرف مقابل کاربر جاری رو توی یک مچ برمی‌گردونه func OtherUserID(match *models.Match, currentUserID uint) uint { if match.UserOneID == currentUserID { diff --git a/internal/repository/photo_repository.go b/internal/repository/photo_repository.go index be273dc..c65ec03 100644 --- a/internal/repository/photo_repository.go +++ b/internal/repository/photo_repository.go @@ -29,6 +29,18 @@ func (r *PhotoRepository) ListByUserIDs(userIDs []uint) ([]models.Photo, error) return photos, err } +// ListAllPaginated همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول +// برمی‌گردونه (برای نظارت محتوایی پنل ادمین). +func (r *PhotoRepository) ListAllPaginated(limit, offset int) ([]models.Photo, int64, error) { + var total int64 + if err := r.db.Model(&models.Photo{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var photos []models.Photo + err := r.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&photos).Error + return photos, total, err +} + func (r *PhotoRepository) CountByUser(userID uint) (int64, error) { var count int64 err := r.db.Model(&models.Photo{}).Where("user_id = ?", userID).Count(&count).Error diff --git a/internal/repository/report_repository.go b/internal/repository/report_repository.go new file mode 100644 index 0000000..6745c6b --- /dev/null +++ b/internal/repository/report_repository.go @@ -0,0 +1,53 @@ +package repository + +import ( + "gorm.io/gorm" + + "dating-app/internal/models" +) + +type ReportRepository struct { + db *gorm.DB +} + +func NewReportRepository(db *gorm.DB) *ReportRepository { + return &ReportRepository{db: db} +} + +func (r *ReportRepository) Create(report *models.Report) error { + return r.db.Create(report).Error +} + +func (r *ReportRepository) ListByUser(userID uint) ([]models.Report, error) { + var reports []models.Report + err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&reports).Error + return reports, err +} + +// CountsByUserIDs تعداد گزارش‌های ثبت‌شده روی چند کاربر رو با هم، در یک +// کوئری برمی‌گردونه (برای نشون دادن نشان تعداد گزارش روی لیست کاربران، بدون +// یک کوئری جدا به‌ازای هر کاربر). +func (r *ReportRepository) CountsByUserIDs(userIDs []uint) (map[uint]int64, error) { + result := make(map[uint]int64, len(userIDs)) + if len(userIDs) == 0 { + return result, nil + } + + type row struct { + UserID uint + Count int64 + } + var rows []row + err := r.db.Model(&models.Report{}). + Select("user_id, count(*) as count"). + Where("user_id IN ?", userIDs). + Group("user_id"). + Find(&rows).Error + if err != nil { + return nil, err + } + for _, rr := range rows { + result[rr.UserID] = rr.Count + } + return result, nil +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index 1796ee2..89066cf 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -80,6 +80,25 @@ func (r *UserRepository) ListPendingApproval() ([]models.User, error) { return users, err } +// ListAll همه‌ی کاربران رو صفحه‌بندی‌شده برمی‌گردونه (برای صفحه‌ی «همه‌ی +// کاربران» پنل ادمین)؛ search روی شماره موبایل و اسم کوچیک می‌گرده. +func (r *UserRepository) ListAll(search string, limit, offset int) ([]models.User, int64, error) { + query := r.db.Model(&models.User{}) + if search != "" { + like := "%" + search + "%" + query = query.Where("phone ILIKE ? OR first_name ILIKE ?", like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var users []models.User + err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error + return users, total, err +} + // ListDiscoverable کاندیداهای بخش دوستیابی رو برمی‌گردونه: تاییدشده، فعال، // جنسیت مخالف کاربر (به‌عنوان ساده‌ترین فرض پیش‌فرض؛ فیلد ترجیح جنسیتی/گرایش // هنوز توی سیستم وجود نداره) و کسایی که قبلاً روشون سواپ نزده. diff --git a/internal/router/router.go b/internal/router/router.go index c5abd00..da8c27a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -96,10 +96,22 @@ func Setup( // -------- پنل ادمین (نیاز به ورود + نقش ادمین) -------- admin := api.Group("/admin", authRequired, middleware.AdminRequired()) { + admin.GET("/users", h.Admin.ListAllUsers) admin.GET("/users/pending", h.Admin.ListPendingUsers) admin.GET("/users/:id", h.Admin.GetUserDetail) admin.POST("/users/:id/approve", h.Admin.ApproveUser) admin.POST("/users/:id/reject", h.Admin.RejectUser) + admin.POST("/users/:id/block", h.Admin.BlockUser) + admin.POST("/users/:id/unblock", h.Admin.UnblockUser) + admin.POST("/users/:id/report", h.Admin.ReportUser) + admin.GET("/users/:id/reports", h.Admin.ListUserReports) + + // رفقا (مچ‌ها) + admin.GET("/matches", h.Admin.ListAllMatches) + + // نظارت بر عکس‌های آپلودشده + admin.GET("/photos", h.Admin.ListAllPhotos) + admin.DELETE("/photos/:id", h.Admin.DeletePhoto) admin.GET("/categories/:key", h.Admin.GetCategory) admin.PATCH("/categories/:key", h.Admin.UpdateCategory) diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go index 20a3d9e..3626b25 100644 --- a/internal/service/admin_service.go +++ b/internal/service/admin_service.go @@ -9,20 +9,40 @@ import ( "dating-app/internal/repository" ) -var ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است") +var ( + ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است") + ErrBlockReasonEmpty = errors.New("دلیل بلاک کردن نمی‌تواند خالی باشد") + ErrReportReasonEmpty = errors.New("دلیل گزارش نمی‌تواند خالی باشد") +) type AdminService struct { - optionRepo *repository.OptionRepository - questionRepo *repository.QuestionRepository - userRepo *repository.UserRepository + optionRepo *repository.OptionRepository + questionRepo *repository.QuestionRepository + userRepo *repository.UserRepository + reportRepo *repository.ReportRepository + matchRepo *repository.MatchRepository + photoRepo *repository.PhotoRepository + profileService *ProfileService } func NewAdminService( optionRepo *repository.OptionRepository, questionRepo *repository.QuestionRepository, userRepo *repository.UserRepository, + reportRepo *repository.ReportRepository, + matchRepo *repository.MatchRepository, + photoRepo *repository.PhotoRepository, + profileService *ProfileService, ) *AdminService { - return &AdminService{optionRepo: optionRepo, questionRepo: questionRepo, userRepo: userRepo} + return &AdminService{ + optionRepo: optionRepo, + questionRepo: questionRepo, + userRepo: userRepo, + reportRepo: reportRepo, + matchRepo: matchRepo, + photoRepo: photoRepo, + profileService: profileService, + } } // ---------------- بررسی و تایید کاربران ---------------- @@ -87,6 +107,216 @@ func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) return user, nil } +// ---------------- همه‌ی کاربران، بلاک/آنبلاک، گزارش ---------------- + +// AdminUserListItem یک کاربر به‌همراه تعداد گزارش‌هاش، برای لیست کامل کاربران +type AdminUserListItem struct { + models.User + ReportCount int64 `json:"report_count"` +} + +// ListAllUsers همه‌ی کاربران رو صفحه‌بندی‌شده و قابل‌جستجو (شماره/اسم) برمی‌گردونه. +func (s *AdminService) ListAllUsers(search string, page, pageSize int) ([]AdminUserListItem, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 25 + } + users, total, err := s.userRepo.ListAll(search, pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + + ids := make([]uint, len(users)) + for i, u := range users { + ids[i] = u.ID + } + counts, err := s.reportRepo.CountsByUserIDs(ids) + if err != nil { + return nil, 0, err + } + + items := make([]AdminUserListItem, len(users)) + for i, u := range users { + items[i] = AdminUserListItem{User: u, ReportCount: counts[u.ID]} + } + return items, total, nil +} + +// BlockUser از ورود کاربر به اپ جلوگیری می‌کنه (چه توکن معتبر داشته باشه چه نه). +func (s *AdminService) BlockUser(id uint, reason string) (*models.User, error) { + if reason == "" { + return nil, ErrBlockReasonEmpty + } + user, err := s.userRepo.FindByID(id) + if err != nil { + return nil, err + } + + now := time.Now() + updates := map[string]interface{}{ + "state": models.UserStateBlocked, + "blocked_reason": reason, + "blocked_at": now, + } + if err := s.userRepo.UpdateFields(user, updates); err != nil { + return nil, err + } + user.State = models.UserStateBlocked + user.BlockedReason = &reason + user.BlockedAt = &now + return user, nil +} + +// UnblockUser دوباره اجازه‌ی ورود به کاربر می‌ده. +func (s *AdminService) UnblockUser(id uint) (*models.User, error) { + user, err := s.userRepo.FindByID(id) + if err != nil { + return nil, err + } + + updates := map[string]interface{}{ + "state": models.UserStateActive, + "blocked_reason": nil, + "blocked_at": nil, + } + if err := s.userRepo.UpdateFields(user, updates); err != nil { + return nil, err + } + user.State = models.UserStateActive + user.BlockedReason = nil + user.BlockedAt = nil + return user, nil +} + +// ReportUser یک یادداشت نظارتی روی کاربر ثبت می‌کنه (مستقل از بلاک کردن). +func (s *AdminService) ReportUser(id uint, reason string) (*models.Report, error) { + if reason == "" { + return nil, ErrReportReasonEmpty + } + if _, err := s.userRepo.FindByID(id); err != nil { + return nil, err + } + report := &models.Report{UserID: 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) +} + +// ---------------- مچ‌ها (رفقا) ---------------- + +// AdminMatchView یک مچ به‌همراه اطلاعات خلاصه‌ی هر دو طرف، برای نمایش به ادمین. +type AdminMatchView struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + UserOneID uint `json:"user_one_id"` + UserOneName string `json:"user_one_name"` + UserOnePhone string `json:"user_one_phone"` + UserTwoID uint `json:"user_two_id"` + UserTwoName string `json:"user_two_name"` + UserTwoPhone string `json:"user_two_phone"` +} + +// ListAllMatches همه‌ی مچ‌های اپ رو صفحه‌بندی‌شده برمی‌گردونه («رفقا»یی که با +// هم مچ شدن)، جدیدترین اول. +func (s *AdminService) ListAllMatches(page, pageSize int) ([]AdminMatchView, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 25 + } + matches, total, err := s.matchRepo.ListAllPaginated(pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + + userIDs := make([]uint, 0, len(matches)*2) + for _, m := range matches { + userIDs = append(userIDs, m.UserOneID, m.UserTwoID) + } + users, err := s.userRepo.FindByIDs(userIDs) + if err != nil { + return nil, 0, err + } + userByID := make(map[uint]models.User, len(users)) + for _, u := range users { + userByID[u.ID] = u + } + + views := make([]AdminMatchView, 0, len(matches)) + for _, m := range matches { + one := userByID[m.UserOneID] + two := userByID[m.UserTwoID] + views = append(views, AdminMatchView{ + ID: m.ID, + CreatedAt: m.CreatedAt, + UserOneID: one.ID, + UserOneName: one.FirstName, + UserOnePhone: one.Phone, + UserTwoID: two.ID, + UserTwoName: two.FirstName, + UserTwoPhone: two.Phone, + }) + } + return views, total, nil +} + +// ---------------- نظارت بر عکس‌ها ---------------- + +// AdminPhotoView یک عکس آپلودشده به‌همراه اطلاعات صاحبش، برای نظارت ادمین. +type AdminPhotoView struct { + models.Photo + OwnerName string `json:"owner_name"` + OwnerPhone string `json:"owner_phone"` +} + +// ListAllPhotos همه‌ی عکس‌های آپلودشده‌ی اپ رو صفحه‌بندی‌شده، جدیدترین اول برمی‌گردونه. +func (s *AdminService) ListAllPhotos(page, pageSize int) ([]AdminPhotoView, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 200 { + pageSize = 50 + } + photos, total, err := s.photoRepo.ListAllPaginated(pageSize, (page-1)*pageSize) + if err != nil { + return nil, 0, err + } + + ids := make([]uint, len(photos)) + for i, p := range photos { + ids[i] = p.UserID + } + users, err := s.userRepo.FindByIDs(ids) + if err != nil { + return nil, 0, err + } + userByID := make(map[uint]models.User, len(users)) + for _, u := range users { + userByID[u.ID] = u + } + + views := make([]AdminPhotoView, len(photos)) + for i, p := range photos { + owner := userByID[p.UserID] + views[i] = AdminPhotoView{Photo: p, OwnerName: owner.FirstName, OwnerPhone: owner.Phone} + } + return views, total, nil +} + +// DeletePhotoAdmin یک عکس رو صرف‌نظر از مالکش حذف می‌کنه (برای نظارت محتوایی). +func (s *AdminService) DeletePhotoAdmin(photoID uint) error { + return s.profileService.DeletePhotoAdmin(photoID) +} + // ---------------- دسته‌ها و گزینه‌ها ---------------- func (s *AdminService) GetCategory(key models.CategoryKey) (*models.Category, error) { diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index 6697f78..4447865 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -74,6 +74,19 @@ func (s *ProfileService) DeletePhoto(userID, photoID uint) error { return s.storage.Delete(photo.URL) } +// DeletePhotoAdmin مثل DeletePhoto ولی بدون چک مالکیت؛ برای نظارت محتوایی +// ادمین که باید بتونه هر عکسی رو صرف‌نظر از صاحبش حذف کنه. +func (s *ProfileService) DeletePhotoAdmin(photoID uint) error { + photo, err := s.photoRepo.FindByID(photoID) + if err != nil { + return err + } + 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) }