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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user