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

304 lines
8.5 KiB
Go
Raw Normal View History

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
}
}
// Compute progress aggregates using only active+published lessons as the denominator so
// that frontend and backend always agree on the percentage.
totalActive, completedActive := 0, 0
for _, l := range lessons {
if !l.IsActive || !l.IsPublished {
continue
}
totalActive++
if p, ok := progress[l.ID]; ok && p.PassedQuiz {
completedActive++
}
}
var progressPct float64
if totalActive > 0 {
progressPct = float64(completedActive) / float64(totalActive) * 100
}
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,
"totalLessons": totalActive,
"completedLessons": completedActive,
"progressPct": progressPct,
"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 || !lesson.IsPublished) && !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
}