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