Files
pargar/backend/internal/api/admin.go
T

883 lines
29 KiB
Go
Raw Normal View History

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
}
u = s.settleAccount(r.Context(), u)
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
}
target, err := s.store.GetUserByID(r.Context(), id)
if err != nil {
writeErr(w, http.StatusNotFound, "کاربر پیدا نشد")
return
}
target = s.settleAccount(r.Context(), target)
if !target.IsFrozen && !target.IsClosed {
writeErr(w, http.StatusConflict, msgDeleteNotFrozen)
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})
}
func (s *Server) handleAdminPublishLesson(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
var body struct {
Publish bool `json:"publish"`
}
if err := bodyJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.PublishLesson(r.Context(), id, body.Publish); err != nil {
writeErr(w, http.StatusInternalServerError, "به‌روزرسانی وضعیت درس ممکن نشد")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) handleAdminPublishChapter(w http.ResponseWriter, r *http.Request) {
id := routeID(r, "id")
var body struct {
Publish bool `json:"publish"`
}
if err := bodyJSON(r, &body); err != nil {
writeErr(w, http.StatusBadRequest, "دادهٔ ارسالی نامعتبر است")
return
}
if err := s.store.PublishChapter(r.Context(), id, body.Publish); 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 ""
}