Add admin user moderation: block/unblock, reports, matches list, photo review

- 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.
This commit is contained in:
2026-09-20 05:30:14 +03:30
parent 769e76313f
commit 1dc96e86fc
19 changed files with 628 additions and 25 deletions
+235 -5
View File
@@ -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) {