1dc96e86fc
- 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.
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"time"
|
|
|
|
"dating-app/internal/dto"
|
|
"dating-app/internal/models"
|
|
"dating-app/internal/repository"
|
|
)
|
|
|
|
var (
|
|
ErrUserNotOnboarded = errors.New("کاربر هنوز آنبوردینگ را تکمیل نکرده است")
|
|
ErrBlockReasonEmpty = errors.New("دلیل بلاک کردن نمیتواند خالی باشد")
|
|
ErrReportReasonEmpty = errors.New("دلیل گزارش نمیتواند خالی باشد")
|
|
)
|
|
|
|
type AdminService struct {
|
|
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,
|
|
reportRepo: reportRepo,
|
|
matchRepo: matchRepo,
|
|
photoRepo: photoRepo,
|
|
profileService: profileService,
|
|
}
|
|
}
|
|
|
|
// ---------------- بررسی و تایید کاربران ----------------
|
|
|
|
// GetUser یک کاربر رو برای نمایش به ادمین (بررسی درخواست) برمیگردونه
|
|
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()
|
|
}
|
|
|
|
// ApproveUser کاربر رو تایید میکنه تا بتونه وارد صفحهی دوستیابی بشه
|
|
func (s *AdminService) ApproveUser(id uint) (*models.User, error) {
|
|
user, err := s.userRepo.FindByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !user.IsOnboarded {
|
|
return nil, ErrUserNotOnboarded
|
|
}
|
|
|
|
now := time.Now()
|
|
updates := map[string]interface{}{
|
|
"approval_status": models.ApprovalApproved,
|
|
"rejection_reason": nil,
|
|
"reviewed_at": now,
|
|
}
|
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
|
return nil, err
|
|
}
|
|
user.ApprovalStatus = models.ApprovalApproved
|
|
user.RejectionReason = nil
|
|
user.ReviewedAt = &now
|
|
return user, nil
|
|
}
|
|
|
|
// RejectUser درخواست عضویت کاربر رو با یک دلیل مشخص رد میکنه
|
|
func (s *AdminService) RejectUser(id uint, reason string) (*models.User, error) {
|
|
user, err := s.userRepo.FindByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !user.IsOnboarded {
|
|
return nil, ErrUserNotOnboarded
|
|
}
|
|
|
|
now := time.Now()
|
|
updates := map[string]interface{}{
|
|
"approval_status": models.ApprovalRejected,
|
|
"rejection_reason": reason,
|
|
"reviewed_at": now,
|
|
}
|
|
if err := s.userRepo.UpdateFields(user, updates); err != nil {
|
|
return nil, err
|
|
}
|
|
user.ApprovalStatus = models.ApprovalRejected
|
|
user.RejectionReason = &reason
|
|
user.ReviewedAt = &now
|
|
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) {
|
|
return s.optionRepo.GetCategoryByKey(key)
|
|
}
|
|
|
|
func (s *AdminService) UpdateCategory(key models.CategoryKey, req dto.UpdateCategoryRequest) (*models.Category, error) {
|
|
cat, err := s.optionRepo.GetCategoryByKey(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Title != nil {
|
|
cat.Title = *req.Title
|
|
}
|
|
if req.MinSelect != nil {
|
|
cat.MinSelect = *req.MinSelect
|
|
}
|
|
if req.MaxSelect != nil {
|
|
cat.MaxSelect = *req.MaxSelect
|
|
}
|
|
if err := s.optionRepo.UpdateCategory(cat); err != nil {
|
|
return nil, err
|
|
}
|
|
return cat, nil
|
|
}
|
|
|
|
func (s *AdminService) ListOptions(key models.CategoryKey) ([]models.Option, error) {
|
|
return s.optionRepo.ListOptionsByCategoryKeyAdmin(key)
|
|
}
|
|
|
|
func (s *AdminService) CreateOption(key models.CategoryKey, req dto.CreateOptionRequest) (*models.Option, error) {
|
|
cat, err := s.optionRepo.GetCategoryByKey(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opt := &models.Option{
|
|
CategoryID: cat.ID,
|
|
Title: req.Title,
|
|
SortOrder: req.SortOrder,
|
|
IsActive: true,
|
|
}
|
|
if err := s.optionRepo.CreateOption(opt); err != nil {
|
|
return nil, err
|
|
}
|
|
return opt, nil
|
|
}
|
|
|
|
func (s *AdminService) UpdateOption(id uint, req dto.UpdateOptionRequest) (*models.Option, error) {
|
|
opt, err := s.optionRepo.FindOptionByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Title != nil {
|
|
opt.Title = *req.Title
|
|
}
|
|
if req.SortOrder != nil {
|
|
opt.SortOrder = *req.SortOrder
|
|
}
|
|
if req.IsActive != nil {
|
|
opt.IsActive = *req.IsActive
|
|
}
|
|
if err := s.optionRepo.UpdateOption(opt); err != nil {
|
|
return nil, err
|
|
}
|
|
return opt, nil
|
|
}
|
|
|
|
func (s *AdminService) DeleteOption(id uint) error {
|
|
return s.optionRepo.DeleteOption(id)
|
|
}
|
|
|
|
// ---------------- سوالات پروفایل ----------------
|
|
|
|
func (s *AdminService) ListQuestions() ([]models.ProfileQuestion, error) {
|
|
return s.questionRepo.ListAllAdmin()
|
|
}
|
|
|
|
func (s *AdminService) CreateQuestion(req dto.CreateProfileQuestionRequest) (*models.ProfileQuestion, error) {
|
|
q := &models.ProfileQuestion{
|
|
Text: req.Text,
|
|
SortOrder: req.SortOrder,
|
|
IsActive: true,
|
|
}
|
|
if err := s.questionRepo.Create(q); err != nil {
|
|
return nil, err
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
func (s *AdminService) UpdateQuestion(id uint, req dto.UpdateProfileQuestionRequest) (*models.ProfileQuestion, error) {
|
|
q, err := s.questionRepo.FindByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Text != nil {
|
|
q.Text = *req.Text
|
|
}
|
|
if req.SortOrder != nil {
|
|
q.SortOrder = *req.SortOrder
|
|
}
|
|
if req.IsActive != nil {
|
|
q.IsActive = *req.IsActive
|
|
}
|
|
if err := s.questionRepo.Update(q); err != nil {
|
|
return nil, err
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
func (s *AdminService) DeleteQuestion(id uint) error {
|
|
return s.questionRepo.Delete(id)
|
|
}
|