284 lines
7.9 KiB
Go
284 lines
7.9 KiB
Go
package api
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"pargar/backend/internal/models"
|
||
|
|
"pargar/backend/internal/observability"
|
||
|
|
"pargar/backend/internal/service"
|
||
|
|
"pargar/backend/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// handleTree returns the full curriculum with per-user progress, quiz state and lock resolution.
|
||
|
|
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||
|
|
u := currentUser(r)
|
||
|
|
chapters, err := s.store.ListChapters(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری فصلها ممکن نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
lessons, err := s.store.ListLessons(r.Context(), u.Role.IsStaff())
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری درسها ممکن نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
progress, err := s.store.ProgressMap(r.Context(), u.ID)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری پیشرفت ممکن نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
attempts := map[int64]*models.QuizAttempt{}
|
||
|
|
for _, l := range lessons {
|
||
|
|
at, err := s.store.LastAttempt(r.Context(), u.ID, l.ID)
|
||
|
|
if err == nil && at != nil {
|
||
|
|
attempts[l.ID] = at
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type lessonView struct {
|
||
|
|
models.Lesson
|
||
|
|
Progress *models.LessonProgress `json:"progress"`
|
||
|
|
QuizStatus string `json:"quizStatus"`
|
||
|
|
Locked bool `json:"locked"`
|
||
|
|
}
|
||
|
|
type chapterView struct {
|
||
|
|
models.Chapter
|
||
|
|
Lessons []lessonView `json:"lessons"`
|
||
|
|
}
|
||
|
|
|
||
|
|
complete := map[int64]bool{}
|
||
|
|
for _, l := range lessons {
|
||
|
|
if p, ok := progress[l.ID]; ok && p.PassedQuiz {
|
||
|
|
complete[l.ID] = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
out := make([]chapterView, 0, len(chapters))
|
||
|
|
for _, ch := range chapters {
|
||
|
|
cv := chapterView{Chapter: ch, Lessons: []lessonView{}}
|
||
|
|
for _, l := range lessons {
|
||
|
|
if l.ChapterID != ch.ID {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
lv := lessonView{Lesson: l}
|
||
|
|
if !u.Role.IsStaff() {
|
||
|
|
lv.VideoKey = "" // never expose storage keys to students
|
||
|
|
}
|
||
|
|
if p, ok := progress[l.ID]; ok {
|
||
|
|
lv.Progress = p
|
||
|
|
}
|
||
|
|
if at, ok := attempts[l.ID]; ok {
|
||
|
|
lv.QuizStatus = at.Status
|
||
|
|
}
|
||
|
|
// root lesson (no prerequisite) is always unlocked; otherwise require prerequisite passed quiz
|
||
|
|
if l.RequiresLessonID != nil && !complete[*l.RequiresLessonID] {
|
||
|
|
lv.Locked = true
|
||
|
|
}
|
||
|
|
cv.Lessons = append(cv.Lessons, lv)
|
||
|
|
}
|
||
|
|
out = append(out, cv)
|
||
|
|
}
|
||
|
|
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"chapters": out,
|
||
|
|
"me": map[string]any{
|
||
|
|
"xp": u.XP,
|
||
|
|
"streak": u.StreakCurrent,
|
||
|
|
"hearts": u.Hearts,
|
||
|
|
"isLocked": u.IsLocked,
|
||
|
|
"role": u.Role,
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleLesson(w http.ResponseWriter, r *http.Request) {
|
||
|
|
u := currentUser(r)
|
||
|
|
id := routeID(r, "id")
|
||
|
|
lesson, err := s.store.GetLesson(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusNotFound, "درس پیدا نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !s.allowLessonAccess(w, r, lesson) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
progress, _ := s.store.GetProgress(r.Context(), u.ID, id)
|
||
|
|
attempt, _ := s.store.LastAttempt(r.Context(), u.ID, id)
|
||
|
|
|
||
|
|
videoKey := strings.TrimSpace(lesson.VideoKey)
|
||
|
|
usingSample := false
|
||
|
|
if videoKey == "" {
|
||
|
|
// Always give the player something to show when a lesson has no uploaded video yet.
|
||
|
|
videoKey = "sample.mp4"
|
||
|
|
usingSample = true
|
||
|
|
}
|
||
|
|
videoURL := s.signMediaURL(videoKey)
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"lesson": lesson,
|
||
|
|
"videoUrl": videoURL,
|
||
|
|
"videoIsSample": usingSample,
|
||
|
|
"progress": progress,
|
||
|
|
"quizStatus": attemptStatus(attempt),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func attemptStatus(at *models.QuizAttempt) string {
|
||
|
|
if at == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return at.Status
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||
|
|
u := currentUser(r)
|
||
|
|
id := routeID(r, "id")
|
||
|
|
lesson, err := s.store.GetLesson(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusNotFound, "درس پیدا نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !s.allowLessonAccess(w, r, lesson) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
pos, err := s.watch.ResumePosition(r.Context(), u.ID, id)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, "بارگذاری موقعیت ادامهٔ درس ممکن نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
lastSeq, _ := s.store.MaxHeartbeatSeq(r.Context(), u.ID, id)
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{"position": pos, "lastSeq": lastSeq})
|
||
|
|
}
|
||
|
|
|
||
|
|
type heartbeatRequest struct {
|
||
|
|
Position float64 `json:"position"`
|
||
|
|
Delta float64 `json:"delta"`
|
||
|
|
Seq int `json:"seq"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||
|
|
u := currentUser(r)
|
||
|
|
id := routeID(r, "id")
|
||
|
|
lesson, err := s.store.GetLesson(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusNotFound, "درس پیدا نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !s.allowLessonAccess(w, r, lesson) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
previousProgress, _ := s.store.GetProgress(r.Context(), u.ID, id)
|
||
|
|
wasQuizUnlocked := previousProgress != nil && previousProgress.QuizUnlocked
|
||
|
|
var req heartbeatRequest
|
||
|
|
if err := bodyJSON(r, &req); err != nil {
|
||
|
|
writeErr(w, http.StatusBadRequest, "ضربان پخش نامعتبر است")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ctx, span := observability.StartSpan(r.Context(), "watch.heartbeat")
|
||
|
|
defer span.End()
|
||
|
|
pg, err := s.watch.Heartbeat(ctx, u.ID, lesson, req.Position, req.Delta, req.Seq)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusInternalServerError, "ثبت پیشرفت پخش ممکن نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
observability.Heartbeats.Inc()
|
||
|
|
unlockedNow := !wasQuizUnlocked && pg.QuizUnlocked
|
||
|
|
if unlockedNow {
|
||
|
|
observability.Activity("lesson.quiz_unlocked",
|
||
|
|
"user_id", u.ID,
|
||
|
|
"lesson_id", id,
|
||
|
|
"watched_pct", round2(pg.WatchedPct),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"watchedSeconds": round2(pg.WatchedSeconds),
|
||
|
|
"watchedPct": round2(pg.WatchedPct),
|
||
|
|
"position": req.Position,
|
||
|
|
"quizUnlocked": pg.QuizUnlocked,
|
||
|
|
"quizUnlockedNow": unlockedNow,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||
|
|
u := currentUser(r)
|
||
|
|
id := routeID(r, "id")
|
||
|
|
lesson, err := s.store.GetLesson(r.Context(), id)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusNotFound, "درس پیدا نشد")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if !s.allowLessonAccess(w, r, lesson) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
pg, err := s.store.GetProgress(r.Context(), u.ID, id)
|
||
|
|
if err != nil {
|
||
|
|
writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if pg.WatchedPct < float64(lesson.CompletionThresholdPct) || !pg.QuizUnlocked {
|
||
|
|
writeErr(w, http.StatusForbidden, "حداقل زمان تماشا تکمیل نشده است")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
||
|
|
"ok": true,
|
||
|
|
"quizUnlocked": pg.QuizUnlocked,
|
||
|
|
"watchedPct": round2(pg.WatchedPct),
|
||
|
|
"message": "آزمون باز شد",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func round2(v float64) float64 {
|
||
|
|
return float64(int64(v*100+0.5)) / 100
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) allowLessonAccess(w http.ResponseWriter, r *http.Request, lesson *models.Lesson) bool {
|
||
|
|
u := currentUser(r)
|
||
|
|
if u == nil {
|
||
|
|
writeErr(w, http.StatusUnauthorized, "نشست معتبر نیست")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if u.IsLocked && !u.Role.IsStaff() {
|
||
|
|
writeErrCode(w, http.StatusLocked, CodeLocked, service.ErrLocked.Error())
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
if !lesson.IsActive && !u.Role.IsStaff() {
|
||
|
|
writeErrCode(w, http.StatusNotFound, CodeNotFound, "درس پیدا نشد")
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
err := s.lessonAccessError(r.Context(), u.ID, lesson)
|
||
|
|
if err == nil {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
if errors.Is(err, service.ErrLessonLocked) {
|
||
|
|
writeErrCode(w, http.StatusForbidden, CodeForbidden, service.ErrLessonLocked.Error())
|
||
|
|
} else {
|
||
|
|
writeInternalErr(w, "lesson.access", err, "بررسی دسترسی به درس ممکن نشد")
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) lessonAccessError(ctx context.Context, userID int64, lesson *models.Lesson) error {
|
||
|
|
if lesson.RequiresLessonID == nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
progress, err := s.store.GetProgress(ctx, userID, *lesson.RequiresLessonID)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, store.ErrNotFound) {
|
||
|
|
return service.ErrLessonLocked
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if !progress.PassedQuiz {
|
||
|
|
return service.ErrLessonLocked
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func routeID(r *http.Request, key string) int64 {
|
||
|
|
id, _ := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||
|
|
return id
|
||
|
|
}
|