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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Add draft/published state to chapters and lessons.
|
||||
-- Existing content is published immediately so nothing goes dark on deploy.
|
||||
-- New lessons created via the admin API default to is_published = false (draft).
|
||||
|
||||
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS is_published BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
UPDATE lessons SET is_published = true;
|
||||
UPDATE chapters SET is_published = true;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Add manual approval flow for certificates.
|
||||
-- Existing certs keep status='issued'. New completions create status='pending'
|
||||
-- until an admin or mentor explicitly issues the certificate.
|
||||
|
||||
ALTER TABLE certificates ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'issued';
|
||||
ALTER TABLE certificates ALTER COLUMN public_id DROP NOT NULL;
|
||||
|
||||
UPDATE certificates SET status = 'issued';
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('certificate_enabled', 'true'),
|
||||
('certificate_requires_approval', 'true')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -107,10 +107,11 @@ func (u *User) ApplyFreezeState(now time.Time) {
|
||||
type Certificate struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"userId"`
|
||||
PublicID string `json:"publicId"`
|
||||
PublicID string `json:"publicId,omitempty"`
|
||||
FullName string `json:"fullName"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type PhysicalOrderStatus string
|
||||
@@ -164,6 +165,7 @@ type Chapter struct {
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
IsPublished bool `json:"isPublished"`
|
||||
}
|
||||
|
||||
type Lesson struct {
|
||||
@@ -180,6 +182,7 @@ type Lesson struct {
|
||||
SortOrder int `json:"sortOrder"`
|
||||
XPReward int `json:"xpReward"`
|
||||
IsActive bool `json:"isActive"`
|
||||
IsPublished bool `json:"isPublished"`
|
||||
}
|
||||
|
||||
type MCQQuestion struct {
|
||||
|
||||
+107
-26
@@ -618,7 +618,7 @@ func IsUniqueViolation(err error) bool {
|
||||
|
||||
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active`).Scan(&n)
|
||||
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -627,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM lesson_progress lp
|
||||
JOIN lessons l ON l.id = lp.lesson_id
|
||||
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active`, userID).Scan(&n)
|
||||
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
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 {
|
||||
return true, nil
|
||||
}
|
||||
active, err := s.CountActiveLessons(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -648,42 +653,106 @@ func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool,
|
||||
|
||||
func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
|
||||
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
|
||||
FROM certificates WHERE user_id=$1`, userID)
|
||||
return scanCertificate(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
|
||||
FROM certificates WHERE public_id=$1`, publicID)
|
||||
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
|
||||
FROM certificates WHERE public_id=$1 AND status='issued'`, publicID)
|
||||
return scanCertificate(row)
|
||||
}
|
||||
|
||||
func scanCertificate(row rowScanner) (*models.Certificate, error) {
|
||||
var c models.Certificate
|
||||
err := row.Scan(&c.ID, &c.UserID, &c.PublicID, &c.FullName, &c.IssuedAt, &c.RevokedAt)
|
||||
var publicID *string
|
||||
err := row.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if publicID != nil {
|
||||
c.PublicID = *publicID
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO certificates (user_id, public_id, full_name)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO certificates (user_id, public_id, full_name, status)
|
||||
VALUES ($1, $2, $3, 'issued')
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name,
|
||||
public_id = EXCLUDED.public_id,
|
||||
status = 'issued',
|
||||
revoked_at = NULL
|
||||
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at`,
|
||||
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
|
||||
userID, publicID, fullName)
|
||||
return scanCertificate(row)
|
||||
}
|
||||
|
||||
func (s *Store) CreatePendingCertificate(ctx context.Context, userID int64, fullName string) (*models.Certificate, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO certificates (user_id, public_id, full_name, status)
|
||||
VALUES ($1, NULL, $2, 'pending')
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
|
||||
userID, fullName)
|
||||
cert, err := scanCertificate(row)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return s.GetCertificateByUser(ctx, userID)
|
||||
}
|
||||
return cert, err
|
||||
}
|
||||
|
||||
func (s *Store) IssuePendingCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE certificates SET
|
||||
public_id = $1,
|
||||
full_name = $2,
|
||||
status = 'issued',
|
||||
revoked_at = NULL,
|
||||
issued_at = NOW()
|
||||
WHERE user_id = $3
|
||||
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
|
||||
publicID, fullName, userID)
|
||||
return scanCertificate(row)
|
||||
}
|
||||
|
||||
func (s *Store) RevokeCertificate(ctx context.Context, userID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListCertificatesByStatus(ctx context.Context, status string) ([]models.Certificate, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status
|
||||
FROM certificates c
|
||||
WHERE c.status=$1 AND c.revoked_at IS NULL
|
||||
ORDER BY c.issued_at DESC`, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []models.Certificate
|
||||
for rows.Next() {
|
||||
var c models.Certificate
|
||||
var publicID *string
|
||||
if err := rows.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if publicID != nil {
|
||||
c.PublicID = *publicID
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const physicalOrderCols = `id, certificate_id, user_id, status, note,
|
||||
recipient_name, phone, address, city, postal_code, tracking_code,
|
||||
window_ends_at, created_at, updated_at`
|
||||
@@ -879,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
// ---- Chapters / Lessons / Questions ----
|
||||
|
||||
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
|
||||
c.IsPublished = false
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`,
|
||||
c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.ID)
|
||||
INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished).Scan(&c.ID)
|
||||
return c.ID, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`,
|
||||
c.Title, c.Description, c.Icon, c.SortOrder, id)
|
||||
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6`,
|
||||
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PublishChapter(ctx context.Context, id int64, publish bool) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE chapters SET is_published=$1 WHERE id=$2`, publish, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -898,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order FROM chapters ORDER BY sort_order, id`)
|
||||
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -906,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
||||
var out []models.Chapter
|
||||
for rows.Next() {
|
||||
var c models.Chapter
|
||||
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder, &c.IsPublished); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
@@ -915,22 +990,28 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
|
||||
}
|
||||
|
||||
func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) {
|
||||
l.IsPublished = false
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
|
||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`,
|
||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
|
||||
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive).Scan(&l.ID)
|
||||
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished).Scan(&l.ID)
|
||||
return l.ID, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
|
||||
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12
|
||||
WHERE id=$13`,
|
||||
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13
|
||||
WHERE id=$14`,
|
||||
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
|
||||
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, id)
|
||||
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PublishLesson(ctx context.Context, id int64, publish bool) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE lessons SET is_published=$1 WHERE id=$2`, publish, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -942,11 +1023,11 @@ func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
|
||||
func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT id, chapter_id, title, description, video_key, duration_seconds,
|
||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active
|
||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published
|
||||
FROM lessons WHERE id=$1`, id)
|
||||
var l models.Lesson
|
||||
err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
|
||||
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive)
|
||||
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -955,9 +1036,9 @@ func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error)
|
||||
|
||||
func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) {
|
||||
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
|
||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active FROM lessons`
|
||||
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons`
|
||||
if !includeInactive {
|
||||
q += ` WHERE is_active`
|
||||
q += ` WHERE is_active AND is_published`
|
||||
}
|
||||
q += ` ORDER BY chapter_id, sort_order, id`
|
||||
rows, err := s.pool.Query(ctx, q)
|
||||
@@ -969,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models
|
||||
for rows.Next() {
|
||||
var l models.Lesson
|
||||
if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
|
||||
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive); err != nil {
|
||||
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
|
||||
Reference in New Issue
Block a user