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
This commit is contained in:
ahp
2026-10-20 17:51:22 +03:30
parent 617d8ff680
commit 161610049c
9 changed files with 232 additions and 69 deletions
+50
View File
@@ -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)
}
}
+3 -1
View File
@@ -5,7 +5,9 @@ import (
) )
func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCommunity(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"socials": s.loadSocials(r.Context()), "socials": s.loadSocials(ctx),
"contributors": s.loadContributors(ctx),
}) })
} }
+8 -2
View File
@@ -95,10 +95,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
return scoredList[i].userID < scoredList[j].userID 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)) entries := make([]lbEntry, 0, len(scoredList))
for i, sc := range scoredList { for i, sc := range scoredList {
usr, err := s.store.GetUserByID(context.Background(), sc.userID) usr, ok := usersMap[sc.userID]
if err != nil { if !ok {
continue continue
} }
s.signUserMedia(usr) s.signUserMedia(usr)
-63
View File
@@ -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}) 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) { func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
env := "production" env := "production"
+4 -1
View File
@@ -282,8 +282,11 @@ func (s *Server) perUserRateLimit(limit int64, scope string, next http.HandlerFu
// allowWSHandshake enforces a per-IP budget on WebSocket upgrades. // allowWSHandshake enforces a per-IP budget on WebSocket upgrades.
// Returns false when the caller should receive 429 (already written by caller). // Returns false when the caller should receive 429 (already written by caller).
func (s *Server) allowWSHandshake(r *http.Request) bool { func (s *Server) allowWSHandshake(r *http.Request) bool {
if s.cfg.DevMode {
return true
}
if s.redis == nil { if s.redis == nil {
return s.cfg.DevMode return false
} }
ip := clientAddress(r) ip := clientAddress(r)
if ip == "" { if ip == "" {
+1 -1
View File
@@ -106,6 +106,7 @@ func newRouter(s *Server) http.Handler {
mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate)) mux.HandleFunc("POST /api/admin/users/{id}/certificate/revoke", staff(s.handleAdminRevokeCertificate))
mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings)) mux.HandleFunc("GET /api/admin/settings", admin(s.handleAdminGetSettings))
mux.HandleFunc("PUT /api/admin/settings", admin(s.handleAdminPutSettings)) 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", admin(s.handleAdminChatExport))
mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats)) mux.HandleFunc("GET /api/admin/chat-export/stats", admin(s.handleAdminChatExportStats))
mux.HandleFunc("POST /api/admin/chat-export/restore", admin(s.handleAdminChatExportRestore)) 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/achievements", s.requireAuth(s.handleMeAchievements))
mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity)) mux.HandleFunc("GET /api/me/activity", s.requireAuth(s.handleMeActivity))
mux.HandleFunc("GET /api/me/export", s.requireAuth(s.handleMeExport)) 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("GET /api/meta", s.handleMeta)
mux.HandleFunc("POST /api/bot/send", s.handleBotSend) mux.HandleFunc("POST /api/bot/send", s.handleBotSend)
mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates) mux.HandleFunc("GET /api/bot/updates", s.handleBotUpdates)
+137
View File
@@ -3,9 +3,11 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"unicode/utf8" "unicode/utf8"
"pargar/backend/internal/observability" "pargar/backend/internal/observability"
@@ -93,12 +95,138 @@ func validateSocialLinks(v string) string {
return "" 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) { func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
keys := []string{ keys := []string{
"physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days", "physical_cert_enabled", "physical_cert_price_irr", "physical_cert_window_days",
"sponsors", "sponsors",
"social_links", "social_links",
"contributors",
"hearts_max", "hearts_max",
"hearts_regen_minutes", "hearts_regen_minutes",
"hearts_lock_on_empty", "hearts_lock_on_empty",
@@ -163,6 +291,7 @@ func (s *Server) handleAdminPutSettings(w http.ResponseWriter, r *http.Request)
"registration_enabled": true, "registration_enabled": true,
"registration_require_whitelist": true, "registration_require_whitelist": true,
"social_links": true, "social_links": true,
"contributors": true,
"hearts_max": true, "hearts_max": true,
"hearts_regen_minutes": true, "hearts_regen_minutes": true,
"hearts_lock_on_empty": 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) writeErr(w, http.StatusBadRequest, msg)
return return
} }
case "contributors":
if v == "" {
v = "[]"
}
if msg := validateContributors(v); msg != "" {
writeErr(w, http.StatusBadRequest, msg)
return
}
case "hearts_max": case "hearts_max":
n, err := strconv.Atoi(v) n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > store.MaxHearts { if err != nil || n < 1 || n > store.MaxHearts {
@@ -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;
+23 -1
View File
@@ -148,6 +148,27 @@ func (s *Store) GetUserByID(ctx context.Context, id int64) (*models.User, error)
return u, err 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 { func (s *Store) UpdateUserStats(ctx context.Context, userID int64, xp int, streak int, streakLongest int, lastActivity *time.Time) error {
_, err := s.pool.Exec(ctx, ` _, err := s.pool.Exec(ctx, `
UPDATE users SET xp=$1, streak_current=$2, streak_longest=$3, last_activity_date=$4 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) { func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
// An issued (non-revoked) certificate is permanent proof of completion regardless // An issued (non-revoked) certificate is permanent proof of completion regardless
// of subsequent curriculum changes (e.g. new lessons added after the student finished). // 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 return true, nil
} }
active, err := s.CountActiveLessons(ctx) active, err := s.CountActiveLessons(ctx)