feat: implement 8 features across full stack

- 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
This commit is contained in:
ahp
2026-10-18 10:14:22 +03:30
parent 75e2892e36
commit 03610bc3d3
23 changed files with 1641 additions and 474 deletions
+64 -14
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"net/http"
"sort"
"strconv"
"time"
@@ -15,21 +16,40 @@ type lbEntry struct {
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())
entries := make([]lbEntry, 0, 20)
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()
@@ -39,12 +59,16 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
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 empty (e.g. dev bootstrap)
// 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 LIMIT 20`,
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() {
@@ -56,30 +80,50 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
}
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: i + 1,
AvatarVariant: usr.AvatarVariant, AvatarPalette: usr.AvatarPalette, AvatarPhoto: usr.AvatarPhoto,
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,
})
}
// my position
// Current user's position (may be outside top 20).
myRank := -1
var myXP float64
if s.redis != nil {
rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
if err == nil {
if rank, err := s.redis.ZRevRank(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myRank = int(rank) + 1
}
score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result()
if err == nil {
if score, err := s.redis.ZScore(context.Background(), key, strconv.FormatInt(u.ID, 10)).Result(); err == nil {
myXP = score
}
}
@@ -88,8 +132,14 @@ func (s *Server) handleWeeklyLeaderboard(w http.ResponseWriter, r *http.Request)
entries = []lbEntry{}
}
writeJSON(w, http.StatusOK, map[string]any{
"week": service.ISOWeek(time.Now()),
"entries": entries,
"me": map[string]any{"rank": myRank, "xp": myXP, "streak": u.StreakCurrent},
"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)),
},
})
}