Files

288 lines
10 KiB
Go
Raw Permalink Normal View History

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
}
}