Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
895 lines
29 KiB
Go
895 lines
29 KiB
Go
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) handleAdminGetUser(w http.ResponseWriter, r *http.Request) {
|
|
id := routeID(r, "id")
|
|
if id <= 0 {
|
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
|
return
|
|
}
|
|
u, err := s.store.GetUserByID(r.Context(), id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": u})
|
|
}
|
|
|
|
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
|
|
}
|
|
if _, err := s.store.GetUserByID(r.Context(), id); err != nil {
|
|
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
|
|
return
|
|
}
|
|
var req struct {
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
if err := bodyJSON(r, &req); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
|
|
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)})
|
|
}
|