package api import ( "context" "crypto/rand" "encoding/hex" "net/http" "strconv" "strings" "pargar/backend/internal/models" "pargar/backend/internal/observability" "pargar/backend/internal/store" ) func newPublicID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } 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 } // certRequiresApproval always returns true — certificates are never auto-issued. // The DB setting is kept for observability but cannot override the hard gate. func (s *Server) certRequiresApproval(_ context.Context) bool { return true } // issueCertificateIfEligible creates a pending certificate row when the user has // completed the curriculum. It never issues (publishes) the certificate directly; // that step requires explicit admin action via handleAdminIssueCertificate. 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 } if !ok { return nil, false, nil } if existing, err := s.store.GetCertificateByUser(ctx, user.ID); err == nil { if existing.RevokedAt == nil { return existing, false, nil } } else if err != store.ErrNotFound { return nil, false, err } // Always create a pending row — never auto-issue. cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name) if err != nil { return nil, false, err } return cert, cert.Status == "pending", nil } func (s *Server) handleIssueCertificate(w http.ResponseWriter, r *http.Request) { u := currentUser(r) cert, created, err := s.issueCertificateIfEligible(r.Context(), u) if err != nil { writeErr(w, http.StatusInternalServerError, "صدور گواهینامه ممکن نشد") return } if cert == nil { writeErr(w, http.StatusConflict, "هنوز همه درس‌های فعال را پاس نکرده‌اید") return } if created { observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID) } writeJSON(w, http.StatusOK, map[string]any{"certificate": cert, "created": created}) } func (s *Server) handleMyCertificate(w http.ResponseWriter, r *http.Request) { u := currentUser(r) eligible, err := s.store.HasCompletedCurriculum(r.Context(), u.ID) if err != nil { writeInternalErr(w, "certificate.eligible", err, "بارگذاری وضعیت گواهینامه ممکن نشد") return } cert, err := s.store.GetCertificateByUser(r.Context(), u.ID) if err != nil && err != store.ErrNotFound { writeInternalErr(w, "certificate.mine", err, "بارگذاری گواهینامه ممکن نشد") return } if err == store.ErrNotFound || (cert != nil && cert.RevokedAt != nil) { cert = nil } order, _ := s.store.GetLatestPhysicalOrder(r.Context(), u.ID) writeJSON(w, http.StatusOK, map[string]any{ "certificate": cert, "eligible": eligible, "physicalOrder": order, "physical": s.physicalSettings(r.Context()), }) } func setCertificateNoIndex(w http.ResponseWriter) { w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") } func (s *Server) handlePublicCertificate(w http.ResponseWriter, r *http.Request) { setCertificateNoIndex(w) publicID := r.PathValue("publicId") if publicID == "" { writeErr(w, http.StatusBadRequest, "شناسه نامعتبر است") return } cert, err := s.store.GetCertificateByPublicID(r.Context(), publicID) if err != nil || cert.RevokedAt != nil || cert.Status != "issued" { writeErr(w, http.StatusNotFound, "گواهینامه پیدا نشد") return } writeJSON(w, http.StatusOK, map[string]any{ "certificate": map[string]any{ "publicId": cert.PublicID, "fullName": cert.FullName, "issuedAt": cert.IssuedAt, }, "course": "پرگار", }) } func (s *Server) physicalSettings(ctx context.Context) map[string]any { enabled := s.cfg.PhysicalCertEnabled price := s.cfg.PhysicalCertPriceIRR days := s.cfg.PhysicalCertWindowDays if v, err := s.store.GetSetting(ctx, "physical_cert_enabled"); err == nil { enabled = v == "true" || v == "1" } if v, err := s.store.GetSetting(ctx, "physical_cert_price_irr"); err == nil { if n, e := strconv.Atoi(v); e == nil { price = n } } if v, err := s.store.GetSetting(ctx, "physical_cert_window_days"); err == nil { if n, e := strconv.Atoi(v); e == nil { days = n } } return map[string]any{ "enabled": enabled, "priceIrr": price, "windowDays": days, } } func (s *Server) handleRequestPhysicalCertificate(w http.ResponseWriter, r *http.Request) { 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.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 } go func() { _ = s.notify.Notify(context.Background(), userID, "progress", "certificate_issued", "گواهینامه صادر شد", "گواهینامه پرگار شما صادر شد. برای مشاهده به صفحه گواهینامه مراجعه کنید.", "/cap", map[string]any{"publicId": cert.PublicID}) }() 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) { status := r.URL.Query().Get("status") p := parsePageParams(r) orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset) if err != nil { writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد") return } if orders == nil { orders = []models.CertificatePhysicalOrder{} } writeJSON(w, http.StatusOK, map[string]any{ "orders": orders, "total": total, "page": p.Page, "pageSize": p.PageSize, "physical": s.physicalSettings(r.Context()), }) } func (s *Server) handleAdminUpdatePhysicalOrder(w http.ResponseWriter, r *http.Request) { id := routeID(r, "id") var req struct { Status string `json:"status"` Note string `json:"note"` TrackingCode string `json:"trackingCode"` } if err := bodyJSON(r, &req); err != nil { writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است") return } var st models.PhysicalOrderStatus switch req.Status { case "requested", "paid", "shipped", "cancelled": st = models.PhysicalOrderStatus(req.Status) default: writeErr(w, http.StatusBadRequest, "وضعیت نامعتبر است") return } order, err := s.store.UpdatePhysicalOrder(r.Context(), id, st, req.Note, strings.TrimSpace(req.TrackingCode)) if err != nil { writeErr(w, http.StatusNotFound, "سفارش پیدا نشد") return } title, body, route := physicalStatusCopy(st, order.TrackingCode) go func() { _ = s.notify.Notify(context.Background(), order.UserID, "progress", "physical_order_"+string(st), title, body, route, map[string]any{"orderId": order.ID, "status": string(st)}) }() observability.Activity("certificate.physical_updated", "order_id", id, "status", string(st), "actor_id", currentUser(r).ID) writeJSON(w, http.StatusOK, map[string]any{"order": order}) } func physicalStatusCopy(st models.PhysicalOrderStatus, tracking string) (title, body, route string) { route = "/notifications" switch st { case models.PhysicalPaid: return "پرداخت گواهینامه تأیید شد", "وضعیت سفارش فیزیکی به «پرداخت‌شده» تغییر کرد.", route case models.PhysicalShipped: msg := "گواهینامه فیزیکی ارسال شد." if tracking != "" { msg += " کد پیگیری: " + tracking } return "گواهینامه ارسال شد", msg, route case models.PhysicalCancelled: return "سفارش فیزیکی لغو شد", "سفارش نسخه فیزیکی شما لغو شد. در صورت نیاز دوباره درخواست دهید.", route default: return "به‌روزرسانی سفارش فیزیکی", "وضعیت سفارش گواهینامه فیزیکی تغییر کرد.", route } }