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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user