- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
deterministic tie-breaking (xp DESC, user_id ASC)
- Mentor chat: md: breakpoints, safe-area-inset-bottom padding
- Learning progress: server-side progressPct, curriculum expansion safety
- Physical orders: hard-disabled with 410 Gone
- Certificate approval: pending/issued flow, admin issue/revoke endpoints,
certificate_requires_approval setting (migration 0031)
- Draft/publish: is_published on lessons and chapters, admin publish UI
(migration 0030)
- Video panel: static thumbnails (no preload), client-side pagination
- Rich editor: TipTap + tiptap-markdown for lesson descriptions,
marked + DOMPurify for SafeMarkdown rendering
146 lines
3.9 KiB
Go
146 lines
3.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"pargar/backend/internal/service"
|
|
)
|
|
|
|
type lbEntry struct {
|
|
UserID int64 `json:"userId"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
XP float64 `json:"xp"`
|
|
Rank int `json:"rank"`
|
|
Tier string `json:"tier"`
|
|
AvatarVariant string `json:"avatarVariant"`
|
|
AvatarPalette string `json:"avatarPalette"`
|
|
AvatarPhoto string `json:"avatarPhoto,omitempty"`
|
|
}
|
|
|
|
// tierFromRank assigns a league tier based on rank relative to total participants.
|
|
// Thresholds: rank 1 = champion, top 10% = gold, top 30% = silver, rest = bronze.
|
|
func tierFromRank(rank, total int) string {
|
|
if rank <= 0 || total <= 0 {
|
|
return "bronze"
|
|
}
|
|
if rank == 1 {
|
|
return "champion"
|
|
}
|
|
if rank*10 <= total {
|
|
return "gold"
|
|
}
|
|
if rank*10 <= total*3 {
|
|
return "silver"
|
|
}
|
|
return "bronze"
|
|
}
|
|
|
|
func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request) {
|
|
u := currentUser(r)
|
|
key := "lb:" + service.ISOWeek(time.Now())
|
|
|
|
type scored struct {
|
|
userID int64
|
|
xp float64
|
|
}
|
|
var scoredList []scored
|
|
var totalParticipants int64
|
|
|
|
if s.redis != nil {
|
|
z, err := s.redis.ZRevRangeWithScores(context.Background(), key, 0, 19).Result()
|
|
if err == nil {
|
|
for _, member := range z {
|
|
id, _ := strconv.ParseInt(member.Member.(string), 10, 64)
|
|
scoredList = append(scoredList, scored{userID: id, xp: member.Score})
|
|
}
|
|
}
|
|
if count, err := s.redis.ZCard(context.Background(), key).Result(); err == nil {
|
|
totalParticipants = count
|
|
}
|
|
}
|
|
|
|
// Fallback to DB if Redis is empty (e.g. dev bootstrap).
|
|
if len(scoredList) == 0 {
|
|
rows, err := s.store.Pool().Query(context.Background(),
|
|
`SELECT user_id, xp FROM weekly_leaderboard WHERE week=$1
|
|
ORDER BY xp DESC, user_id ASC LIMIT 20`,
|
|
service.ISOWeek(time.Now()))
|
|
if err == nil {
|
|
for rows.Next() {
|
|
var id int64
|
|
var xp int
|
|
if rows.Scan(&id, &xp) == nil {
|
|
scoredList = append(scoredList, scored{userID: id, xp: float64(xp)})
|
|
}
|
|
}
|
|
rows.Close()
|
|
}
|
|
if err := s.store.Pool().QueryRow(context.Background(),
|
|
`SELECT COUNT(*) FROM weekly_leaderboard WHERE week=$1`,
|
|
service.ISOWeek(time.Now())).Scan(&totalParticipants); err != nil {
|
|
totalParticipants = int64(len(scoredList))
|
|
}
|
|
}
|
|
|
|
// Deterministic tie-breaking: XP descending, then user_id ascending.
|
|
sort.SliceStable(scoredList, func(i, j int) bool {
|
|
if scoredList[i].xp != scoredList[j].xp {
|
|
return scoredList[i].xp > scoredList[j].xp
|
|
}
|
|
return scoredList[i].userID < scoredList[j].userID
|
|
})
|
|
|
|
entries := make([]lbEntry, 0, len(scoredList))
|
|
for i, sc := range scoredList {
|
|
usr, err := s.store.GetUserByID(context.Background(), sc.userID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s.signUserMedia(usr)
|
|
rank := i + 1
|
|
entries = append(entries, lbEntry{
|
|
UserID: sc.userID,
|
|
Name: usr.Name,
|
|
Email: usr.Email,
|
|
XP: sc.xp,
|
|
Rank: rank,
|
|
Tier: tierFromRank(rank, int(totalParticipants)),
|
|
AvatarVariant: usr.AvatarVariant,
|
|
AvatarPalette: usr.AvatarPalette,
|
|
AvatarPhoto: usr.AvatarPhoto,
|
|
})
|
|
}
|
|
|
|
// Current user's position (may be outside top 20).
|
|
myRank := -1
|
|
var myXP float64
|
|
if s.redis != nil {
|
|
if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
|
|
myRank = int(rank) + 1
|
|
}
|
|
if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
|
|
myXP = score
|
|
}
|
|
}
|
|
|
|
if entries == nil {
|
|
entries = []lbEntry{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"week": service.ISOWeek(time.Now()),
|
|
"entries": entries,
|
|
"totalParticipants": totalParticipants,
|
|
"me": map[string]any{
|
|
"rank": myRank,
|
|
"xp": myXP,
|
|
"streak": u.StreakCurrent,
|
|
"tier": tierFromRank(myRank, int(totalParticipants)),
|
|
},
|
|
})
|
|
}
|