2026-10-07 19:40:26 +03:30
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 10:14:22 +03:30
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 12:09:30 +03:30
|
|
|
// 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 }
|
2026-10-18 10:14:22 +03:30
|
|
|
|
2026-10-18 12:09:30 +03:30
|
|
|
// 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.
|
2026-10-07 19:40:26 +03:30
|
|
|
func (s *Server) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
|
2026-10-18 10:14:22 +03:30
|
|
|
if !s.certEnabled(ctx) {
|
|
|
|
|
return nil, false, nil
|
|
|
|
|
}
|
2026-10-07 19:40:26 +03:30
|
|
|
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
|
|
|
|
|
}
|
2026-10-18 12:09:30 +03:30
|
|
|
// Always create a pending row — never auto-issue.
|
|
|
|
|
cert, err := s.store.CreatePendingCertificate(ctx, user.ID, user.Name)
|
2026-10-07 19:40:26 +03:30
|
|
|
if err != nil {
|
|
|
|
|
return nil, false, err
|
|
|
|
|
}
|
2026-10-18 12:09:30 +03:30
|
|
|
return cert, cert.Status == "pending", nil
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-18 12:09:30 +03:30
|
|
|
if err != nil || cert.RevokedAt != nil || cert.Status != "issued" {
|
2026-10-07 19:40:26 +03:30
|
|
|
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) {
|
2026-10-18 10:14:22 +03:30
|
|
|
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, "کاربر پیدا نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
existing, err := s.store.GetCertificateByUser(r.Context(), userID)
|
|
|
|
|
if err != nil && err != store.ErrNotFound {
|
|
|
|
|
writeInternalErr(w, "cert.admin.issue", err, "بارگذاری گواهینامه ممکن نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
if existing != nil && existing.RevokedAt == nil && existing.Status == "issued" {
|
|
|
|
|
writeErr(w, http.StatusConflict, "گواهینامه قبلاً صادر شده است")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
publicID, err := newPublicID()
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeInternalErr(w, "cert.admin.pubid", err, "تولید شناسه ممکن نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
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)
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
if err != nil {
|
|
|
|
|
writeInternalErr(w, "cert.admin.create", err, "صدور گواهینامه ممکن نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
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, "لغو گواهینامه ممکن نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
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")
|
2026-10-07 19:40:26 +03:30
|
|
|
if err != nil {
|
2026-10-18 10:14:22 +03:30
|
|
|
writeInternalErr(w, "cert.admin.pending", err, "بارگذاری گواهینامههای در انتظار ممکن نشد")
|
2026-10-07 19:40:26 +03:30
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 10:14:22 +03:30
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": certs})
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleAdminListPhysicalOrders(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
status := r.URL.Query().Get("status")
|
2026-10-09 19:05:59 +03:30
|
|
|
p := parsePageParams(r)
|
|
|
|
|
orders, total, err := s.store.ListPhysicalOrders(r.Context(), status, p.PageSize, p.Offset)
|
2026-10-07 19:40:26 +03:30
|
|
|
if err != nil {
|
|
|
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارشها ممکن نشد")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-09 19:05:59 +03:30
|
|
|
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()),
|
|
|
|
|
})
|
2026-10-07 19:40:26 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|