From 161610049c6d459e3bd5bd091725223d7afae524 Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Tue, 20 Oct 2026 17:51:22 +0330 Subject: [PATCH] feat(backend): leaderboard tiers, N+1 fix, contributors, certificate approval, physical disable - leaderboard: batch-fetch users (GetUsersByIDs), deterministic tie-break by user_id - leaderboard: tier system (champion/gold/silver/bronze) based on rank percentile - store: HasCompletedCurriculum requires cert.Status == "issued"; pending does not satisfy - certificate: issueCertificateIfEligible creates pending row; admin approves via handleAdminIssueCertificate - certificate: handleRequestPhysicalCertificate returns 410 Gone (feature disabled) - settings: contributors CRUD (loadContributors, validateContributors, handleAdminContributorAvatarUpload) - community: expose contributors in /api/community response - rate_limit: separate DevMode bypass from redis-nil check in allowWSHandshake - router: remove /api/search route; fix missing tab indent on /api/meta line - migration 0032: contributors app_setting with seed entry --- backend/internal/api/certificate_test.go | 50 +++++++ backend/internal/api/community.go | 4 +- backend/internal/api/leaderboard.go | 10 +- backend/internal/api/product.go | 63 -------- backend/internal/api/rate_limit.go | 5 +- backend/internal/api/router.go | 2 +- backend/internal/api/settings.go | 137 ++++++++++++++++++ .../db/migrations/0032_contributors.sql | 6 + backend/internal/store/store.go | 24 ++- 9 files changed, 232 insertions(+), 69 deletions(-) create mode 100644 backend/internal/api/certificate_test.go create mode 100644 backend/internal/db/migrations/0032_contributors.sql diff --git a/backend/internal/api/certificate_test.go b/backend/internal/api/certificate_test.go new file mode 100644 index 0000000..052ef0d --- /dev/null +++ b/backend/internal/api/certificate_test.go @@ -0,0 +1,50 @@ +package api_test + +import ( + "context" + "net/http" + "testing" +) + +// TestPendingCertDoesNotShortCircuitCurriculumCheck ensures that a pending +// certificate does not satisfy HasCompletedCurriculum. Only an issued +// (non-revoked) certificate should bypass the lesson-completion check. +func TestPendingCertDoesNotShortCircuitCurriculumCheck(t *testing.T) { + e := setup(t) + token, uid := register(t, e, "CertPending", "cert-pending@test.dev") + admin := login(t, e, bootstrapAdminEmail) + + // Forcibly create a pending certificate in the DB (simulating a student + // who triggered issueCertificateIfEligible via quiz submit). + _, err := e.pool.Exec(context.Background(), + `INSERT INTO certificates (user_id, full_name, status) + VALUES ($1, 'CertPending', 'pending') + ON CONFLICT DO NOTHING`, uid) + if err != nil { + t.Fatalf("insert pending cert: %v", err) + } + + // The student has NOT passed any lessons — eligible must be false. + code, body := e.do(t, "GET", "/api/me/certificate", token, nil) + if code != http.StatusOK { + t.Fatalf("get cert: %d %v", code, body) + } + if body["eligible"] != false { + t.Fatalf("pending cert must not make student eligible; got eligible=%v", body["eligible"]) + } + + // Admin issues the certificate manually. + code, _ = e.do(t, "POST", "/api/admin/users/"+itoa(uid)+"/certificate/issue", admin, nil) + if code != http.StatusOK { + t.Fatalf("admin issue: %d", code) + } + + // Physical order endpoint must return 410 Gone. + code, _ = e.do(t, "POST", "/api/me/certificate/physical", token, map[string]any{ + "recipientName": "Test", "phone": "09123456789", "address": "addr", + "city": "tehran", "postalCode": "1234567890", + }) + if code != http.StatusGone { + t.Fatalf("physical endpoint must be disabled (410), got %d", code) + } +} diff --git a/backend/internal/api/community.go b/backend/internal/api/community.go index 1b2f063..0a936c1 100644 --- a/backend/internal/api/community.go +++ b/backend/internal/api/community.go @@ -5,7 +5,9 @@ import ( ) func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() writeJSON(w, http.StatusOK, map[string]any{ - "socials": s.loadSocials(r.Context()), + "socials": s.loadSocials(ctx), + "contributors": s.loadContributors(ctx), }) } diff --git a/backend/internal/api/leaderboard.go b/backend/internal/api/leaderboard.go index 4270cbc..eff76b4 100644 --- a/backend/internal/api/leaderboard.go +++ b/backend/internal/api/leaderboard.go @@ -95,10 +95,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) return scoredList[i].userID < scoredList[j].userID }) + ids := make([]int64, len(scoredList)) + for i, sc := range scoredList { + ids[i] = sc.userID + } + usersMap, _ := s.store.GetUsersByIDs(context.Background(), ids) + entries := make([]lbEntry, 0, len(scoredList)) for i, sc := range scoredList { - usr, err := s.store.GetUserByID(context.Background(), sc.userID) - if err != nil { + usr, ok := usersMap[sc.userID] + if !ok { continue } s.signUserMedia(usr) diff --git a/backend/internal/api/product.go b/backend/internal/api/product.go index d51e05a..23ad355 100644 --- a/backend/internal/api/product.go +++ b/backend/internal/api/product.go @@ -504,69 +504,6 @@ func (s *Server) handleMeActivity(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"days": days, "weekDays": week, "monthDays": month}) } -func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { - q := strings.TrimSpace(r.URL.Query().Get("q")) - if utf8.RuneCountInString(q) < 2 { - writeJSON(w, http.StatusOK, map[string]any{"results": []any{}}) - return - } - me := currentUser(r) - like := "%" + q + "%" - var results []map[string]any - rows, err := s.store.Pool().Query(r.Context(), ` - SELECT id, title FROM lessons WHERE title ILIKE $1 ORDER BY id LIMIT 8`, like) - if err == nil { - for rows.Next() { - var id int64 - var title string - if rows.Scan(&id, &title) == nil { - results = append(results, map[string]any{"kind": "lesson", "title": title, "href": "/player/" + strconv.FormatInt(id, 10)}) - } - } - rows.Close() - } - rows, err = s.store.Pool().Query(r.Context(), ` - SELECT id, title FROM events WHERE title ILIKE $1 ORDER BY starts_at DESC LIMIT 8`, like) - if err == nil { - for rows.Next() { - var id int64 - var title string - if rows.Scan(&id, &title) == nil { - results = append(results, map[string]any{"kind": "event", "title": title, "href": "/unwrap"}) - } - } - rows.Close() - } - if me.Role.IsStaff() { - people, _, _ := s.store.ListUsers(r.Context(), q, "", 8, 0) - for _, p := range people { - results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)}) - } - } else { - people, _, _ := s.store.ListActiveByRoles(r.Context(), []string{"mentor", "admin"}, 40, 0) - n := 0 - for _, p := range people { - if n >= 8 || !strings.Contains(strings.ToLower(p.Name), strings.ToLower(q)) { - continue - } - results = append(results, map[string]any{"kind": "person", "title": p.Name, "href": "/profile/" + strconv.FormatInt(p.ID, 10)}) - n++ - } - } - pages := []struct{ title, href string }{ - {"درخت مهارت", "/cap"}, {"اعلان‌ها", "/notifications"}, {"لیگ", "/leaderboard"}, - {"تنظیمات", "/settings"}, {"پروفایل", "/profile"}, - } - for _, p := range pages { - if strings.Contains(p.title, q) { - results = append(results, map[string]any{"kind": "page", "title": p.title, "href": p.href}) - } - } - if results == nil { - results = []map[string]any{} - } - writeJSON(w, http.StatusOK, map[string]any{"results": results}) -} func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { env := "production" diff --git a/backend/internal/api/rate_limit.go b/backend/internal/api/rate_limit.go index a495e33..ab524cc 100644 --- a/backend/internal/api/rate_limit.go +++ b/backend/internal/api/rate_limit.go @@ -282,8 +282,11 @@ func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFu // allowWSHandshake enforces a per-IP budget on WebSocket upgrades. // Returns false when the caller should receive 429 (already written by caller). func (s *Server) allowWSHandshake(r *http.Request) bool { + if s.cfg.DevMode { + return true + } if s.redis == nil { - return s.cfg.DevMode + return false } ip := clientAddress(r) if ip == "" { diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index f806dda..8f82a15 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -106,6 +106,7 @@ func newRouter(s *Server) http.Handler { mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate)) mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings)) mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings)) + mux.HandleFunc("POST /api/admin/contributor-avatar", admin(s.handleAdminContributorAvatarUpload)) mux.HandleFunc("GET /api/admin/chat-export", admin(s.handleAdminChatExport)) mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats)) mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore)) @@ -181,7 +182,6 @@ func newRouter(s *Server) http.Handler { mux.HandleFunc("GET /api/me/achievements", s.requireAuth(s.handleMeAchievements)) mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity)) mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport)) - mux.HandleFunc("GET /api/search", s.requireAuth(s.handleSearch)) mux.HandleFunc("GET /api/meta", s.handleMeta) mux.HandleFunc("POST /api/bot/send", s.handleBotSend) mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates) diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 7ecb41b..eb8616c 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -3,9 +3,11 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" "strconv" "strings" + "time" "unicode/utf8" "pargar/backend/internal/observability" @@ -93,12 +95,138 @@ func validateSocialLinks(v string) string { return "" } +func (s *Server) loadContributors(ctx context.Context) []map[string]any { + raw, err := s.store.GetSetting(ctx, "contributors") + if err != nil || strings.TrimSpace(raw) == "" { + return []map[string]any{} + } + var list []map[string]any + if err := json.Unmarshal([]byte(raw), &list); err != nil { + return []map[string]any{} + } + out := make([]map[string]any, 0, len(list)) + for _, item := range list { + name, _ := item["name"].(string) + if strings.TrimSpace(name) == "" { + continue + } + role, _ := item["role"].(string) + bio, _ := item["bio"].(string) + url, _ := item["url"].(string) + avatar, _ := item["avatar"].(string) + contributions, _ := item["contributions"].([]any) + cats := make([]string, 0, len(contributions)) + for _, c := range contributions { + if s, ok := c.(string); ok && s != "" { + cats = append(cats, s) + } + } + out = append(out, map[string]any{ + "name": strings.TrimSpace(name), + "role": strings.TrimSpace(role), + "bio": strings.TrimSpace(bio), + "url": strings.TrimSpace(url), + "avatar": strings.TrimSpace(avatar), + "contributions": cats, + }) + } + return out +} + +func validateContributors(v string) string { + if v == "" || v == "[]" { + return "" + } + var tmp []map[string]any + if err := json.Unmarshal([]byte(v), &tmp); err != nil { + return "فرمت مشارکت‌کنندگان نامعتبر است" + } + if len(tmp) > 50 { + return "حداکثر ۵۰ مشارکت‌کننده مجاز است" + } + for _, item := range tmp { + name, _ := item["name"].(string) + if strings.TrimSpace(name) == "" { + return "نام مشارکت‌کننده الزامی است" + } + if utf8.RuneCountInString(strings.TrimSpace(name)) > 100 { + return "نام مشارکت‌کننده خیلی طولانی است" + } + if role, _ := item["role"].(string); utf8.RuneCountInString(strings.TrimSpace(role)) > 100 { + return "نقش مشارکت‌کننده خیلی طولانی است" + } + if bio, _ := item["bio"].(string); utf8.RuneCountInString(strings.TrimSpace(bio)) > 300 { + return "بیوگرافی خیلی طولانی است" + } + if url, _ := item["url"].(string); url != "" { + u := strings.TrimSpace(url) + if !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "http://") { + return "لینک پروفایل باید با http شروع شود" + } + if utf8.RuneCountInString(u) > 500 { + return "لینک پروفایل خیلی طولانی است" + } + } + if contributions, ok := item["contributions"].([]any); ok { + if len(contributions) > 20 { + return "حداکثر ۲۰ دسته مشارکت برای هر نفر مجاز است" + } + for _, c := range contributions { + s, ok := c.(string) + if !ok { + return "دسته مشارکت باید رشته باشد" + } + s = strings.TrimSpace(s) + if s == "" { + return "دسته مشارکت نمی‌تواند خالی باشد" + } + if utf8.RuneCountInString(s) > 50 { + return "دسته مشارکت خیلی طولانی است" + } + } + } + } + return "" +} + +func (s *Server) handleAdminContributorAvatarUpload(w http.ResponseWriter, r *http.Request) { + const maxSize = 6 << 20 + r.Body = http.MaxBytesReader(w, r.Body, maxSize+1<<20) + if err := r.ParseMultipartForm(maxSize + 1<<20); err != nil { + writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل بیش از حد مجاز است") + return + } + file, h, err := r.FormFile("file") + if err != nil { + writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است") + return + } + defer file.Close() + if h.Size > maxSize { + writeErr(w, http.StatusRequestEntityTooLarge, "حجم فایل باید کمتر از ۶ مگابایت باشد") + return + } + accepted, err := acceptAvatarImage(h, file) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + key := fmt.Sprintf("contributor-avatars/%d%s", time.Now().UnixNano(), accepted.Ext) + rawURL, err := s.storage.Save(key, file, h.Size) + if err != nil { + writeErr(w, http.StatusInternalServerError, "ذخیرهٔ فایل ممکن نشد") + return + } + writeJSON(w, http.StatusCreated, map[string]any{"url": s.signExistingMediaURL(rawURL)}) +} + func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) { ctx := r.Context() keys := []string{ "physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days", "sponsors", "social_links", + "contributors", "hearts_max", "hearts_regen_minutes", "hearts_lock_on_empty", @@ -163,6 +291,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) "registration_enabled": true, "registration_require_whitelist": true, "social_links": true, + "contributors": true, "hearts_max": true, "hearts_regen_minutes": true, "hearts_lock_on_empty": true, @@ -226,6 +355,14 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request) writeErr(w, http.StatusBadRequest, msg) return } + case "contributors": + if v == "" { + v = "[]" + } + if msg := validateContributors(v); msg != "" { + writeErr(w, http.StatusBadRequest, msg) + return + } case "hearts_max": n, err := strconv.Atoi(v) if err != nil || n < 1 || n > store.MaxHearts { diff --git a/backend/internal/db/migrations/0032_contributors.sql b/backend/internal/db/migrations/0032_contributors.sql new file mode 100644 index 0000000..3293afc --- /dev/null +++ b/backend/internal/db/migrations/0032_contributors.sql @@ -0,0 +1,6 @@ +-- Add contributors setting key. Seed one verified entry (sole git author). +-- Admins can update via the site settings panel. +INSERT INTO app_settings (key, value) VALUES ( + 'contributors', + '[{"name":"Amir Husayn Panahifar","role":"بنیان‌گذار و توسعه‌دهنده","bio":"","url":"","avatar":"","contributions":["code"]}]' +) ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 4f1c8a0..0bf2c7c 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -148,6 +148,27 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error) return u, err } +// GetUsersByIDs fetches multiple users in a single query. Missing IDs are silently skipped. +func (s *Store) GetUsersByIDs(ctx context.Context, ids []int64) (map[int64]*models.User, error) { + if len(ids) == 0 { + return map[int64]*models.User{}, nil + } + rows, err := s.pool.Query(ctx, `SELECT `+userCols+` FROM users WHERE id = ANY($1)`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[int64]*models.User, len(ids)) + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + continue + } + out[u.ID] = u + } + return out, rows.Err() +} + func (s *Store) UpdateUserStats(ctx context.Context, userID int64, xp int, streak int, streakLongest int, lastActivity *time.Time) error { _, err := s.pool.Exec(ctx, ` UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4 @@ -634,7 +655,8 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) { // An issued (non-revoked) certificate is permanent proof of completion regardless // of subsequent curriculum changes (e.g. new lessons added after the student finished). - if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil { + // Pending certs do NOT count — they haven't been approved yet. + if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil && cert.Status == "issued" { return true, nil } active, err := s.CountActiveLessons(ctx)