feat(backend): implement HTTP API, auth, chat, and admin surfaces

Expose learning, registration controls, mentor chat, realtime WebSocket, and staff administration.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent 3ee609337c
commit 422e6ed289
58 changed files with 11756 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
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")
orders, err := s.store.ListPhysicalOrders(r.Context(), status)
if err != nil {
writeErr(w, http.StatusInternalServerError, "بارگذاری سفارش‌ها ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"orders": orders, "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
}
}