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:
+2
-1
@@ -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")
|
||||
|
||||
@@ -55,6 +55,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&models.Swipe{},
|
||||
&models.Match{},
|
||||
&models.Message{},
|
||||
&models.Report{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+17
-12
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 کاندیداهای بخش دوستیابی رو برمیگردونه: تاییدشده، فعال،
|
||||
// جنسیت مخالف کاربر (بهعنوان سادهترین فرض پیشفرض؛ فیلد ترجیح جنسیتی/گرایش
|
||||
// هنوز توی سیستم وجود نداره) و کسایی که قبلاً روشون سواپ نزده.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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