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:
@@ -575,6 +575,38 @@ func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPublishLesson(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
var body struct {
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
if err := bodyJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.PublishLesson(r.Context(), id, body.Publish); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت درس ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminPublishChapter(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
var body struct {
|
||||
Publish bool `json:"publish"`
|
||||
}
|
||||
if err := bodyJSON(r, &body); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.PublishChapter(r.Context(), id, body.Publish); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی وضعیت فصل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---- questions ----
|
||||
|
||||
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pargar/backend/internal/models"
|
||||
"pargar/backend/internal/observability"
|
||||
@@ -22,7 +21,24 @@ func newPublicID() (string, error) {
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (s *Server) certEnabled(ctx context.Context) bool {
|
||||
if v, err := s.store.GetSetting(ctx, "certificate_enabled"); err == nil {
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) certRequiresApproval(ctx context.Context) bool {
|
||||
if v, err := s.store.GetSetting(ctx, "certificate_requires_approval"); err == nil {
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
|
||||
if !s.certEnabled(ctx) {
|
||||
return nil, false, nil
|
||||
}
|
||||
ok, err := s.store.HasCompletedCurriculum(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -37,6 +53,13 @@ func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.Us
|
||||
} else if err != store.ErrNotFound {
|
||||
return nil, false, err
|
||||
}
|
||||
if s.certRequiresApproval(ctx) {
|
||||
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return cert, cert.Status == "pending", nil
|
||||
}
|
||||
publicID, err := newPublicID()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
@@ -140,90 +163,66 @@ func (s *Server) physicalSettings(ctx context.Context) map[string]any {
|
||||
}
|
||||
|
||||
func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
settings := s.physicalSettings(r.Context())
|
||||
if enabled, _ := settings["enabled"].(bool); !enabled {
|
||||
writeErr(w, http.StatusBadRequest, "درخواست نسخه فیزیکی فعلاً فعال نیست")
|
||||
return
|
||||
}
|
||||
cert, err := s.store.GetCertificateByUser(r.Context(), u.ID)
|
||||
if err != nil || cert.RevokedAt != nil {
|
||||
writeErr(w, http.StatusConflict, "ابتدا گواهینامه دیجیتال را دریافت کنید")
|
||||
return
|
||||
}
|
||||
days, _ := settings["windowDays"].(int)
|
||||
if days <= 0 {
|
||||
days = 30
|
||||
}
|
||||
windowEnds := cert.IssuedAt.Add(time.Duration(days) * 24 * time.Hour)
|
||||
if time.Now().After(windowEnds) {
|
||||
writeErr(w, http.StatusConflict, "مهلت درخواست نسخه فیزیکی به پایان رسیده است")
|
||||
return
|
||||
}
|
||||
if existing, err := s.store.GetLatestPhysicalOrder(r.Context(), u.ID); err == nil {
|
||||
if existing.Status != models.PhysicalCancelled {
|
||||
writeErr(w, http.StatusConflict, "سفارش فیزیکی فعال دارید")
|
||||
return
|
||||
}
|
||||
}
|
||||
var req struct {
|
||||
RecipientName string `json:"recipientName"`
|
||||
Phone string `json:"phone"`
|
||||
Address string `json:"address"`
|
||||
City string `json:"city"`
|
||||
PostalCode string `json:"postalCode"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
ship := models.CertificatePhysicalOrder{
|
||||
RecipientName: strings.TrimSpace(req.RecipientName),
|
||||
Phone: normalizePhone(req.Phone),
|
||||
Address: strings.TrimSpace(req.Address),
|
||||
City: strings.TrimSpace(req.City),
|
||||
PostalCode: strings.TrimSpace(req.PostalCode),
|
||||
}
|
||||
if ship.RecipientName == "" {
|
||||
ship.RecipientName = u.Name
|
||||
}
|
||||
if ship.Phone == "" {
|
||||
ship.Phone = normalizePhone(u.Phone)
|
||||
}
|
||||
if ship.Phone == "" || !validPhone(ship.Phone) {
|
||||
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
|
||||
return
|
||||
}
|
||||
if ship.Address == "" || ship.City == "" {
|
||||
writeErr(w, http.StatusBadRequest, "آدرس و شهر برای ارسال الزامی است")
|
||||
return
|
||||
}
|
||||
if len(ship.Address) > 500 || len(ship.City) > 80 || len(ship.RecipientName) > 120 {
|
||||
writeErr(w, http.StatusBadRequest, "اطلاعات ارسال بیش از حد طولانی است")
|
||||
return
|
||||
}
|
||||
order, err := s.store.CreatePhysicalOrder(r.Context(), cert.ID, u.ID, windowEnds, ship)
|
||||
writeErr(w, http.StatusGone, "سفارش فیزیکی موقتاً غیرفعال است")
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminIssueCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
userID := routeID(r, "id")
|
||||
u, err := s.store.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد")
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
existing, err := s.store.GetCertificateByUser(r.Context(), userID)
|
||||
if err != nil && err != store.ErrNotFound {
|
||||
writeInternalErr(w, "cert.admin.issue", err, "بارگذاری گواهینامه ممکن نشد")
|
||||
return
|
||||
}
|
||||
if existing != nil && existing.RevokedAt == nil && existing.Status == "issued" {
|
||||
writeErr(w, http.StatusConflict, "گواهینامه قبلاً صادر شده است")
|
||||
return
|
||||
}
|
||||
publicID, err := newPublicID()
|
||||
if err != nil {
|
||||
writeInternalErr(w, "cert.admin.pubid", err, "تولید شناسه ممکن نشد")
|
||||
return
|
||||
}
|
||||
var cert *models.Certificate
|
||||
if existing != nil {
|
||||
cert, err = s.store.IssuePendingCertificate(r.Context(), userID, publicID, u.Name)
|
||||
} else {
|
||||
cert, err = s.store.CreateCertificate(r.Context(), userID, publicID, u.Name)
|
||||
}
|
||||
if err != nil {
|
||||
writeInternalErr(w, "cert.admin.create", err, "صدور گواهینامه ممکن نشد")
|
||||
return
|
||||
}
|
||||
staff, _ := s.store.ListMentors(r.Context())
|
||||
go func() {
|
||||
for _, m := range staff {
|
||||
if m.Role != models.RoleAdmin {
|
||||
continue
|
||||
}
|
||||
_ = s.notify.Notify(context.Background(), m.ID, "progress", "physical_order",
|
||||
"سفارش گواهینامه فیزیکی", u.Name+" درخواست نسخه فیزیکی ثبت کرد.", "/admin",
|
||||
map[string]any{"orderId": order.ID, "userId": u.ID})
|
||||
}
|
||||
_ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued",
|
||||
"گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap",
|
||||
map[string]any{"publicId": cert.PublicID})
|
||||
}()
|
||||
observability.Activity("certificate.physical_requested", "user_id", u.ID, "order_id", order.ID)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"order": order,
|
||||
"message": "پس از واریز با ادمین هماهنگ کنید",
|
||||
"physical": settings,
|
||||
})
|
||||
observability.Activity("certificate.admin_issued", "user_id", userID, "actor_id", currentUser(r).ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificate": cert})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminRevokeCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
userID := routeID(r, "id")
|
||||
if err := s.store.RevokeCertificate(r.Context(), userID); err != nil {
|
||||
writeInternalErr(w, "cert.admin.revoke", err, "لغو گواهینامه ممکن نشد")
|
||||
return
|
||||
}
|
||||
observability.Activity("certificate.admin_revoked", "user_id", userID, "actor_id", currentUser(r).ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListPendingCertificates(w http.ResponseWriter, r *http.Request) {
|
||||
certs, err := s.store.ListCertificatesByStatus(r.Context(), "pending")
|
||||
if err != nil {
|
||||
writeInternalErr(w, "cert.admin.pending", err, "بارگذاری گواهینامههای در انتظار ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": certs})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTierFromRank(t *testing.T) {
|
||||
cases := []struct {
|
||||
rank, total int
|
||||
want string
|
||||
}{
|
||||
{1, 1, "champion"},
|
||||
{1, 100, "champion"},
|
||||
{1, 0, "bronze"}, // zero total → bronze (guard)
|
||||
{0, 100, "bronze"}, // zero rank → bronze (guard)
|
||||
{10, 100, "gold"}, // exactly 10%
|
||||
{11, 100, "silver"}, // 11%
|
||||
{30, 100, "silver"}, // exactly 30%
|
||||
{31, 100, "bronze"}, // 31%
|
||||
{5, 50, "gold"}, // 10%
|
||||
{15, 50, "silver"}, // exactly 30%
|
||||
{16, 50, "bronze"}, // 32% → bronze
|
||||
{50, 100, "bronze"},
|
||||
{2, 10, "silver"}, // 20% → silver
|
||||
{1, 10, "champion"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := tierFromRank(c.rank, c.total)
|
||||
if got != c.want {
|
||||
t.Errorf("tierFromRank(%d, %d) = %q; want %q", c.rank, c.total, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,9 @@ func newRouter(s *Server) http.Handler {
|
||||
mux.HandleFunc("POST /api/admin/users/{id}/reset-password", admin(s.handleAdminResetPassword))
|
||||
mux.HandleFunc("GET /api/admin/physical-orders", admin(s.handleAdminListPhysicalOrders))
|
||||
mux.HandleFunc("PUT /api/admin/physical-orders/{id}", admin(s.handleAdminUpdatePhysicalOrder))
|
||||
mux.HandleFunc("GET /api/admin/certificates/pending", staff(s.handleAdminListPendingCertificates))
|
||||
mux.HandleFunc("POST /api/admin/users/{id}/certificate/issue", staff(s.handleAdminIssueCertificate))
|
||||
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("GET /api/admin/chat-export", admin(s.handleAdminChatExport))
|
||||
@@ -127,11 +130,13 @@ func newRouter(s *Server) http.Handler {
|
||||
mux.HandleFunc("POST /api/admin/chapters", staff(s.handleAdminCreateChapter))
|
||||
mux.HandleFunc("PUT /api/admin/chapters/{id}", staff(s.handleAdminUpdateChapter))
|
||||
mux.HandleFunc("DELETE /api/admin/chapters/{id}", staff(s.handleAdminDeleteChapter))
|
||||
mux.HandleFunc("POST /api/admin/chapters/{id}/publish", staff(s.handleAdminPublishChapter))
|
||||
|
||||
mux.HandleFunc("GET /api/admin/lessons", staff(s.handleAdminListLessons))
|
||||
mux.HandleFunc("POST /api/admin/lessons", staff(s.handleAdminCreateLesson))
|
||||
mux.HandleFunc("PUT /api/admin/lessons/{id}", staff(s.handleAdminUpdateLesson))
|
||||
mux.HandleFunc("DELETE /api/admin/lessons/{id}", staff(s.handleAdminDeleteLesson))
|
||||
mux.HandleFunc("POST /api/admin/lessons/{id}/publish", staff(s.handleAdminPublishLesson))
|
||||
mux.HandleFunc("GET /api/admin/lessons/{id}/questions", staff(s.handleAdminListQuestions))
|
||||
mux.HandleFunc("POST /api/admin/lessons/{id}/questions", staff(s.handleAdminCreateQuestion))
|
||||
mux.HandleFunc("PUT /api/admin/questions/{id}", staff(s.handleAdminUpdateQuestion))
|
||||
|
||||
@@ -57,6 +57,23 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute progress aggregates using only active+published lessons as the denominator so
|
||||
// that frontend and backend always agree on the percentage.
|
||||
totalActive, completedActive := 0, 0
|
||||
for _, l := range lessons {
|
||||
if !l.IsActive || !l.IsPublished {
|
||||
continue
|
||||
}
|
||||
totalActive++
|
||||
if p, ok := progress[l.ID]; ok && p.PassedQuiz {
|
||||
completedActive++
|
||||
}
|
||||
}
|
||||
var progressPct float64
|
||||
if totalActive > 0 {
|
||||
progressPct = float64(completedActive) / float64(totalActive) * 100
|
||||
}
|
||||
|
||||
out := make([]chapterView, 0, len(chapters))
|
||||
for _, ch := range chapters {
|
||||
cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
|
||||
@@ -84,7 +101,10 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"chapters": out,
|
||||
"chapters": out,
|
||||
"totalLessons": totalActive,
|
||||
"completedLessons": completedActive,
|
||||
"progressPct": progressPct,
|
||||
"me": map[string]any{
|
||||
"xp": u.XP,
|
||||
"streak": u.StreakCurrent,
|
||||
@@ -244,7 +264,7 @@ func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesso
|
||||
writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error())
|
||||
return false
|
||||
}
|
||||
if !lesson.IsActive && !u.Role.IsStaff() {
|
||||
if (!lesson.IsActive || !lesson.IsPublished) && !u.Role.IsStaff() {
|
||||
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user