Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
299 lines
9.9 KiB
Go
299 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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) issueCertificateIfEligible(ctx context.Context, user *models.User) (*models.Certificate, bool, error) {
|
|
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
|
|
}
|
|
publicID, err := newPublicID()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
cert, err := s.store.CreateCertificate(ctx, user.ID, publicID, user.Name)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return cert, true, 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 {
|
|
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) {
|
|
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)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد")
|
|
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})
|
|
}
|
|
}()
|
|
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,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|