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:
@@ -0,0 +1,890 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"pargar/backend/internal/models"
|
||||
"pargar/backend/internal/observability"
|
||||
)
|
||||
|
||||
func (s *Server) handleAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := s.store.Stats(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری آمار ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"stats": stats})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
role := r.URL.Query().Get("role")
|
||||
p := parsePageParams(r)
|
||||
users, total, err := s.store.ListUsers(r.Context(), query, role, p.PageSize, p.Offset)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
|
||||
return
|
||||
}
|
||||
writePage(w, users, total, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Phone string `json:"phone"`
|
||||
SecurityQuestion string `json:"securityQuestion"`
|
||||
SecurityAnswer string `json:"securityAnswer"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Username = strings.ToLower(strings.TrimSpace(req.Username))
|
||||
name := strings.TrimSpace(req.Name)
|
||||
phone := normalizePhone(req.Phone)
|
||||
secQ := strings.TrimSpace(req.SecurityQuestion)
|
||||
secA := strings.TrimSpace(req.SecurityAnswer)
|
||||
|
||||
if msg := validatePersonName(name); msg != "" {
|
||||
writeErr(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if !validEmail(req.Email) {
|
||||
writeErr(w, http.StatusBadRequest, msgEmailInvalid)
|
||||
return
|
||||
}
|
||||
if !validUsername(req.Username) {
|
||||
writeErr(w, http.StatusBadRequest, msgUsernameInvalid)
|
||||
return
|
||||
}
|
||||
if !validPhone(phone) {
|
||||
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
|
||||
return
|
||||
}
|
||||
if msg := validatePasswordOnly(req.Password); msg != "" {
|
||||
writeErr(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
if msg := validateSecurityQA(secQ, secA); msg != "" {
|
||||
writeErr(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
var role models.Role
|
||||
switch req.Role {
|
||||
case "", "student":
|
||||
role = models.RoleStudent
|
||||
case "mentor":
|
||||
role = models.RoleMentor
|
||||
case "admin":
|
||||
role = models.RoleAdmin
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "نقش نامعتبر است")
|
||||
return
|
||||
}
|
||||
if actor := currentUser(r); actor.Role != models.RoleAdmin && role != models.RoleStudent {
|
||||
writeErr(w, http.StatusForbidden, "منتور فقط میتواند حساب هنرجو بسازد")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetUserByEmail(r.Context(), req.Email); err == nil {
|
||||
writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.GetUserByUsername(r.Context(), req.Username); err == nil {
|
||||
writeErr(w, http.StatusConflict, "این شناسهٔ کاربری قبلاً گرفته شده است")
|
||||
return
|
||||
}
|
||||
if phone != "" {
|
||||
if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil {
|
||||
writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است")
|
||||
return
|
||||
}
|
||||
}
|
||||
hash, err := s.auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "پردازش گذرواژه ممکن نشد")
|
||||
return
|
||||
}
|
||||
answerHash, err := s.auth.HashPassword(strings.ToLower(secA))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "پردازش جواب امنیتی ممکن نشد")
|
||||
return
|
||||
}
|
||||
user, err := s.store.CreateUser(r.Context(), name, req.Email, req.Username, hash, phone, secQ, answerHash, role)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ساخت کاربر ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminLockUser(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
if id == actor.ID {
|
||||
writeErr(w, http.StatusBadRequest, "نمیتوانید حساب خودتان را قفل کنید")
|
||||
return
|
||||
}
|
||||
if err := s.canActOn(actor, id); err != nil {
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
if target.Role.IsStaff() {
|
||||
writeErr(w, http.StatusBadRequest, "حساب ادمین و منتور قابل قفل نیست")
|
||||
return
|
||||
}
|
||||
u, err := s.store.LockUser(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
observability.Activity("account.locked", "user_id", id, "actor_id", actor.ID, "reason", "staff")
|
||||
go func() {
|
||||
_ = s.notify.Notify(context.Background(), id, "progress", "account_locked",
|
||||
"حساب توسط منتور محدود شد", "حساب شما توسط "+actor.Name+" محدود شد.", "/unwrap?tab=chats", nil)
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": u})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUnlockUser(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
if err := s.canActOn(actor, id); err != nil {
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
u, err := s.store.UnlockUser(r.Context(), id, actor.ID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
_ = s.store.ResolveUnlockRequests(r.Context(), id)
|
||||
observability.Activity("account.unlocked", "user_id", id, "actor_id", actor.ID)
|
||||
go func() {
|
||||
_ = s.notify.Notify(context.Background(), id, "progress", "account_unlocked",
|
||||
"محدودیت حساب برداشته شد", "محدودیت حساب شما توسط "+actor.Name+" برداشته شد. ۳ جان شما بازگردانده شد.", "/cap", nil)
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": u})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
if id == actor.ID {
|
||||
writeErr(w, http.StatusBadRequest, "نمیتوانید نقش حساب خودتان را تغییر دهید")
|
||||
return
|
||||
}
|
||||
if err := s.canActOn(actor, id); err != nil {
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
var role models.Role
|
||||
switch req.Role {
|
||||
case "student", "mentor", "admin":
|
||||
role = models.Role(req.Role)
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "نقش نامعتبر است")
|
||||
return
|
||||
}
|
||||
if actor.Role != models.RoleAdmin && role != models.RoleStudent {
|
||||
writeErr(w, http.StatusForbidden, "منتور فقط میتواند نقش هنرجو را تنظیم کند")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.Pool().Exec(r.Context(), `UPDATE users SET role=$1 WHERE id=$2`, role.String(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "تغییر نقش ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
if err := s.canActOn(actor, id); err != nil {
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Role *string `json:"role"`
|
||||
Hearts *int `json:"hearts"`
|
||||
XP *int `json:"xp"`
|
||||
Phone *string `json:"phone"`
|
||||
SecurityQuestion *string `json:"securityQuestion"`
|
||||
SecurityAnswer *string `json:"securityAnswer"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
name := target.Name
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
if msg := validatePersonName(name); msg != "" {
|
||||
writeErr(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
email := target.Email
|
||||
if req.Email != nil && strings.TrimSpace(*req.Email) != "" {
|
||||
email = strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
if !validEmail(email) {
|
||||
writeErr(w, http.StatusBadRequest, msgEmailInvalid)
|
||||
return
|
||||
}
|
||||
if email != target.Email {
|
||||
if _, err := s.store.GetUserByEmail(r.Context(), email); err == nil {
|
||||
writeErr(w, http.StatusConflict, "این ایمیل قبلاً ثبت شده است")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
username := target.Username
|
||||
if req.Username != nil && strings.TrimSpace(*req.Username) != "" {
|
||||
username = strings.ToLower(strings.TrimSpace(*req.Username))
|
||||
if !validUsername(username) {
|
||||
writeErr(w, http.StatusBadRequest, msgUsernameInvalid)
|
||||
return
|
||||
}
|
||||
if username != target.Username {
|
||||
if _, err := s.store.GetUserByUsername(r.Context(), username); err == nil {
|
||||
writeErr(w, http.StatusConflict, "این شناسهٔ کاربری قبلاً گرفته شده است")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
phone := target.Phone
|
||||
if req.Phone != nil {
|
||||
phone = normalizePhone(*req.Phone)
|
||||
if !validPhone(phone) {
|
||||
writeErr(w, http.StatusBadRequest, msgPhoneInvalid)
|
||||
return
|
||||
}
|
||||
if phone != "" && phone != target.Phone {
|
||||
if _, err := s.store.GetUserByPhone(r.Context(), phone); err == nil {
|
||||
writeErr(w, http.StatusConflict, "این شماره تلفن قبلاً ثبت شده است")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
hash := ""
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
writeErr(w, http.StatusBadRequest, "برای تغییر رمز از مسیر ریست با سوال امنیتی استفاده کنید")
|
||||
return
|
||||
}
|
||||
secQ := ""
|
||||
secAHash := ""
|
||||
if req.SecurityQuestion != nil {
|
||||
secQ = strings.TrimSpace(*req.SecurityQuestion)
|
||||
if secQ != "" {
|
||||
qn := utf8.RuneCountInString(secQ)
|
||||
if qn < minSecQLen {
|
||||
writeErr(w, http.StatusBadRequest, msgSecQShort)
|
||||
return
|
||||
}
|
||||
if qn > maxSecQLen {
|
||||
writeErr(w, http.StatusBadRequest, msgSecQLong)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.SecurityAnswer != nil && strings.TrimSpace(*req.SecurityAnswer) != "" {
|
||||
if secQ == "" && target.SecurityQuestion == "" {
|
||||
writeErr(w, http.StatusBadRequest, "برای تنظیم جواب، سوال امنیتی هم لازم است")
|
||||
return
|
||||
}
|
||||
if secQ == "" {
|
||||
secQ = target.SecurityQuestion
|
||||
}
|
||||
ans := strings.TrimSpace(*req.SecurityAnswer)
|
||||
an := utf8.RuneCountInString(ans)
|
||||
if an < minSecALen {
|
||||
writeErr(w, http.StatusBadRequest, msgSecAShort)
|
||||
return
|
||||
}
|
||||
if an > maxSecALen {
|
||||
writeErr(w, http.StatusBadRequest, msgSecALong)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
secAHash, err = s.auth.HashPassword(strings.ToLower(ans))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "پردازش جواب امنیتی ممکن نشد")
|
||||
return
|
||||
}
|
||||
}
|
||||
role := target.Role
|
||||
if req.Role != nil {
|
||||
switch *req.Role {
|
||||
case "student", "mentor", "admin":
|
||||
newRole := models.Role(*req.Role)
|
||||
if id == actor.ID && newRole != target.Role {
|
||||
writeErr(w, http.StatusBadRequest, "نمیتوانید نقش حساب خودتان را تغییر دهید")
|
||||
return
|
||||
}
|
||||
if actor.Role != models.RoleAdmin && newRole != models.RoleStudent {
|
||||
writeErr(w, http.StatusForbidden, "منتور فقط میتواند نقش هنرجو را تنظیم کند")
|
||||
return
|
||||
}
|
||||
role = newRole
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "نقش نامعتبر است")
|
||||
return
|
||||
}
|
||||
}
|
||||
hearts, xp := target.Hearts, target.XP
|
||||
if id == actor.ID {
|
||||
// Self-service via admin panel must not mint XP/hearts.
|
||||
req.Hearts = nil
|
||||
req.XP = nil
|
||||
}
|
||||
if req.Hearts != nil {
|
||||
hearts = *req.Hearts
|
||||
}
|
||||
if req.XP != nil {
|
||||
xp = *req.XP
|
||||
}
|
||||
user, err := s.store.UpdateUser(r.Context(), id, name, email, username, hash, phone, secQ, secAHash, role, hearts, xp)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی کاربر ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
if err := s.canActOn(actor, id); err != nil {
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
target, err := s.store.GetUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SecurityAnswer string `json:"securityAnswer"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if target.SecurityAnswerHash == "" {
|
||||
writeErr(w, http.StatusBadRequest, "برای این کاربر سوال امنیتی تعریف نشده است")
|
||||
return
|
||||
}
|
||||
if !s.auth.CheckPassword(target.SecurityAnswerHash, strings.ToLower(strings.TrimSpace(req.SecurityAnswer))) {
|
||||
writeErr(w, http.StatusForbidden, "جواب سوال امنیتی نادرست است")
|
||||
return
|
||||
}
|
||||
if msg := validatePasswordOnly(req.NewPassword); msg != "" {
|
||||
writeErr(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
hash, err := s.auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "پردازش گذرواژه ممکن نشد")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserPassword(r.Context(), id, hash); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ریست گذرواژه ممکن نشد")
|
||||
return
|
||||
}
|
||||
observability.Activity("account.password_reset", "user_id", id, "actor_id", actor.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
actor := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
if id == actor.ID {
|
||||
writeErr(w, http.StatusBadRequest, "نمیتوانید حساب خودتان را حذف کنید")
|
||||
return
|
||||
}
|
||||
if err := s.canActOn(actor, id); err != nil {
|
||||
writeErr(w, http.StatusForbidden, "دسترسی کافی ندارید")
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteUser(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "حذف کاربر ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// canActOn verifies a non-admin actor may only mutate student accounts.
|
||||
func (s *Server) canActOn(actor *models.User, targetID int64) error {
|
||||
if actor.Role == models.RoleAdmin {
|
||||
return nil
|
||||
}
|
||||
target, err := s.store.GetUserByID(context.Background(), targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Role != models.RoleStudent {
|
||||
return fmt.Errorf("فقط میتوانید روی حساب هنرجویان عمل کنید")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- chapters ----
|
||||
|
||||
func (s *Server) handleAdminListChapters(w http.ResponseWriter, r *http.Request) {
|
||||
chapters, err := s.store.ListChapters(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری فصلها ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"chapters": chapters})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateChapter(w http.ResponseWriter, r *http.Request) {
|
||||
var c models.Chapter
|
||||
if err := bodyJSON(r, &c); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateChapter(r.Context(), &c)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ساخت فصل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"chapter": c, "id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateChapter(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
var c models.Chapter
|
||||
if err := bodyJSON(r, &c); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateChapter(r.Context(), id, &c); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی فصل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteChapter(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if err := s.store.DeleteChapter(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "حذف فصل ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---- lessons ----
|
||||
|
||||
func (s *Server) handleAdminListLessons(w http.ResponseWriter, r *http.Request) {
|
||||
lessons, err := s.store.ListLessons(r.Context(), true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری درسها ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"lessons": lessons})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateLesson(w http.ResponseWriter, r *http.Request) {
|
||||
var l models.Lesson
|
||||
if err := bodyJSON(r, &l); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if l.DurationSeconds <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "مدت درس الزامی است")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateLesson(r.Context(), &l)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ساخت درس ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"lesson": l, "id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateLesson(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
var l models.Lesson
|
||||
if err := bodyJSON(r, &l); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateLesson(r.Context(), id, &l); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی درس ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteLesson(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if err := s.store.DeleteLesson(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "حذف درس ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---- questions ----
|
||||
|
||||
func (s *Server) handleAdminListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||
lessonID := routeID(r, "id")
|
||||
questions, err := s.store.ListQuestions(r.Context(), lessonID, true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری پرسشها ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"questions": questions})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
lessonID := routeID(r, "id")
|
||||
var q models.MCQQuestion
|
||||
if err := bodyJSON(r, &q); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
q.LessonID = lessonID
|
||||
if len(q.Options) < 2 {
|
||||
writeErr(w, http.StatusBadRequest, "دستکم ۲ گزینه لازم است")
|
||||
return
|
||||
}
|
||||
if q.AnswerIndex < 0 || q.AnswerIndex >= len(q.Options) {
|
||||
writeErr(w, http.StatusBadRequest, "شاخص پاسخ نامعتبر است")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateQuestion(r.Context(), &q)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ساخت پرسش ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"question": q, "id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
var q models.MCQQuestion
|
||||
if err := bodyJSON(r, &q); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if len(q.Options) < 2 {
|
||||
writeErr(w, http.StatusBadRequest, "دستکم ۲ گزینه لازم است")
|
||||
return
|
||||
}
|
||||
if q.AnswerIndex < 0 || q.AnswerIndex >= len(q.Options) {
|
||||
writeErr(w, http.StatusBadRequest, "شاخص پاسخ نامعتبر است")
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateQuestion(r.Context(), id, &q); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی پرسش ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if err := s.store.DeleteQuestion(r.Context(), id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "حذف پرسش ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// ---- video library ----
|
||||
|
||||
func (s *Server) handleAdminListVideos(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.store.ListVideoAssets(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری ویدیوها ممکن نشد")
|
||||
return
|
||||
}
|
||||
videos := make([]map[string]any, 0, len(rows))
|
||||
for _, v := range rows {
|
||||
videos = append(videos, map[string]any{
|
||||
"key": v.Key,
|
||||
"mime": v.Mime,
|
||||
"size": v.SizeBytes,
|
||||
"lastModified": v.CreatedAt,
|
||||
})
|
||||
}
|
||||
// Also surface keys that exist only on lessons (legacy uploads).
|
||||
lessons, _ := s.store.ListLessons(r.Context(), true)
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range videos {
|
||||
if k, ok := v["key"].(string); ok {
|
||||
seen[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, l := range lessons {
|
||||
if l.VideoKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[l.VideoKey]; ok {
|
||||
continue
|
||||
}
|
||||
seen[l.VideoKey] = struct{}{}
|
||||
videos = append(videos, map[string]any{
|
||||
"key": l.VideoKey,
|
||||
"mime": "video/mp4",
|
||||
"size": 0,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"videos": videos})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
key := strings.TrimSpace(r.URL.Query().Get("key"))
|
||||
if key == "" {
|
||||
writeErr(w, http.StatusBadRequest, "کلید ویدیو الزامی است")
|
||||
return
|
||||
}
|
||||
key = filepath.Base(key)
|
||||
if err := s.store.DeleteVideoAsset(r.Context(), key); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "حذف ویدیو ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUploadVideo(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAdminVideoBytes+1<<20)
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "حجم ویدیو باید کمتر از ۲۰۰ مگابایت باشد")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "فایل ارسال نشده است")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
accepted, err := acceptAdminVideo(header, file)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "۲۰۰ مگابایت") {
|
||||
writeErrCode(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, err.Error())
|
||||
return
|
||||
}
|
||||
writeValidationErr(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer stable mp4-first naming; keep real extension for playback.
|
||||
key := fmt.Sprintf("lesson-%d%s", time.Now().UnixNano(), accepted.Ext)
|
||||
key = filepath.Base(key)
|
||||
url, err := s.storage.Save(key, file, header.Size)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ذخیرهٔ ویدیو ممکن نشد")
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateVideoAsset(r.Context(), key, accepted.Mime, header.Size); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت ویدیو در کتابخانه ممکن نشد")
|
||||
return
|
||||
}
|
||||
observability.VideoUploads.Inc()
|
||||
if actor := currentUser(r); actor != nil {
|
||||
observability.Activity("video.upload", "user_id", actor.ID, "key", key, "size", header.Size, "mime", accepted.Mime)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"url": s.signExistingMediaURL(url),
|
||||
"key": key,
|
||||
"size": header.Size,
|
||||
"mime": accepted.Mime,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- events (admin) ----
|
||||
|
||||
func (s *Server) handleAdminListEvents(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.store.Pool().Query(r.Context(), `
|
||||
SELECT id, title, description, event_type, external_url, starts_at, ends_at, is_active, created_at
|
||||
FROM events ORDER BY starts_at DESC LIMIT 200`)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری رویدادها ممکن نشد")
|
||||
return
|
||||
}
|
||||
var events []models.Event
|
||||
for rows.Next() {
|
||||
var e models.Event
|
||||
if err := rows.Scan(&e.ID, &e.Title, &e.Description, &e.EventType, &e.ExternalURL, &e.StartsAt, &e.EndsAt, &e.IsActive, &e.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری رویدادها ممکن نشد")
|
||||
return
|
||||
}
|
||||
events = append(events, e)
|
||||
}
|
||||
rows.Close()
|
||||
if events == nil {
|
||||
events = []models.Event{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"events": events})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminCreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
admin := currentUser(r)
|
||||
var e models.Event
|
||||
if err := bodyJSON(r, &e); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if msg := validateAdminEvent(&e, true); msg != "" {
|
||||
writeValidationErr(w, msg)
|
||||
return
|
||||
}
|
||||
e.CreatedBy = &admin.ID
|
||||
id, err := s.store.CreateEvent(r.Context(), &e)
|
||||
if err != nil {
|
||||
writeInternalErr(w, "admin.events.create", err, "ساخت رویداد ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"event": e, "id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
var e models.Event
|
||||
if err := bodyJSON(r, &e); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
if msg := validateAdminEvent(&e, false); msg != "" {
|
||||
writeValidationErr(w, msg)
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateEvent(r.Context(), id, &e); err != nil {
|
||||
writeInternalErr(w, "admin.events.update", err, "بهروزرسانی رویداد ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminDeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
id := routeID(r, "id")
|
||||
if err := s.store.DeleteEvent(r.Context(), id); err != nil {
|
||||
writeInternalErr(w, "admin.events.delete", err, "حذف رویداد ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// validateAdminEvent normalizes and checks event fields. create=true defaults isActive to true when unset via zero-value ambiguity is handled by callers sending explicit bool.
|
||||
func validateAdminEvent(e *models.Event, create bool) string {
|
||||
e.Title = strings.TrimSpace(e.Title)
|
||||
e.Description = strings.TrimSpace(e.Description)
|
||||
e.ExternalURL = strings.TrimSpace(e.ExternalURL)
|
||||
e.EventType = strings.TrimSpace(e.EventType)
|
||||
if e.EventType == "" {
|
||||
e.EventType = "meet"
|
||||
}
|
||||
switch e.EventType {
|
||||
case "meet", "zoom", "workshop":
|
||||
default:
|
||||
return "نوع رویداد نامعتبر است"
|
||||
}
|
||||
if e.Title == "" || e.StartsAt.IsZero() || e.EndsAt.IsZero() {
|
||||
return "عنوان، زمان شروع و زمان پایان الزامی هستند"
|
||||
}
|
||||
if utf8.RuneCountInString(e.Title) > 200 {
|
||||
return "عنوان رویداد خیلی طولانی است"
|
||||
}
|
||||
if utf8.RuneCountInString(e.Description) > 5000 {
|
||||
return "توضیح رویداد خیلی طولانی است"
|
||||
}
|
||||
if e.EndsAt.Before(e.StartsAt) {
|
||||
return "زمان پایان باید پس از زمان شروع باشد"
|
||||
}
|
||||
_ = create
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- announcements ----
|
||||
|
||||
func (s *Server) handleAdminAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Route string `json:"route"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
||||
return
|
||||
}
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
req.Body = strings.TrimSpace(req.Body)
|
||||
req.Route = strings.TrimSpace(req.Route)
|
||||
if utf8.RuneCountInString(req.Title) < 3 {
|
||||
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی کوتاه است")
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Title) > 200 {
|
||||
writeErr(w, http.StatusBadRequest, "عنوان اطلاعیه خیلی طولانی است")
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Body) < 8 {
|
||||
writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی کوتاه است")
|
||||
return
|
||||
}
|
||||
if utf8.RuneCountInString(req.Body) > 500 {
|
||||
writeErr(w, http.StatusBadRequest, "متن اطلاعیه خیلی طولانی است")
|
||||
return
|
||||
}
|
||||
if req.Category == "" {
|
||||
req.Category = "event"
|
||||
}
|
||||
switch req.Category {
|
||||
case "event", "progress", "gamification", "mentor":
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "دستهٔ اطلاعیه نامعتبر است")
|
||||
return
|
||||
}
|
||||
userIDs, err := s.store.AllActiveUserIDs(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری کاربران ممکن نشد")
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, uid := range userIDs {
|
||||
_ = s.notify.Notify(context.Background(), uid, req.Category, "announcement",
|
||||
req.Title, req.Body, req.Route, nil)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "recipients": len(userIDs)})
|
||||
}
|
||||
Reference in New Issue
Block a user