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,369 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"pargar/backend/internal/models"
|
||||
"pargar/backend/internal/observability"
|
||||
"pargar/backend/internal/service"
|
||||
"pargar/backend/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) handleQuiz(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, _ := s.store.GetProgress(r.Context(), u.ID, id)
|
||||
if pg == nil || !pg.QuizUnlocked {
|
||||
writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error())
|
||||
return
|
||||
}
|
||||
questions, err := s.store.ListQuestions(r.Context(), id, false)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری پرسشها ممکن نشد")
|
||||
return
|
||||
}
|
||||
views := make([]models.QuizQuestionView, 0, len(questions))
|
||||
for _, q := range questions {
|
||||
views = append(views, models.QuizQuestionView{ID: q.ID, Position: q.Position, Question: q.Question, Options: q.Options})
|
||||
}
|
||||
lastAttempt, _ := s.store.LastAttempt(r.Context(), u.ID, id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"lessonID": id,
|
||||
"duration": lesson.DurationSeconds,
|
||||
"questions": views,
|
||||
"lastAttempt": lastAttempt,
|
||||
"passed": pg.PassedQuiz,
|
||||
"quizStatus": attemptStatus(lastAttempt),
|
||||
})
|
||||
}
|
||||
|
||||
type quizSubmitRequest struct {
|
||||
Answers []int `json:"answers"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuizSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, span := observability.StartSpan(r.Context(), "quiz.submit")
|
||||
defer span.End()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
u := currentUser(r)
|
||||
id := routeID(r, "id")
|
||||
|
||||
// refresh any regenerated hearts before consuming
|
||||
u, _ = s.hearts.RefreshHearts(r.Context(), u)
|
||||
|
||||
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 || !pg.QuizUnlocked {
|
||||
writeErr(w, http.StatusForbidden, service.ErrQuizNotUnlocked.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req quizSubmitRequest
|
||||
if err := bodyJSON(r, &req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "پاسخها نامعتبر هستند")
|
||||
return
|
||||
}
|
||||
if len(req.Answers) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "هیچ پاسخی ارسال نشده است")
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := s.store.ListQuestions(r.Context(), id, true)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری پرسشها ممکن نشد")
|
||||
return
|
||||
}
|
||||
if len(questions) == 0 {
|
||||
writeErr(w, http.StatusBadRequest, "این درس پرسشی ندارد")
|
||||
return
|
||||
}
|
||||
|
||||
// grade server-side; answer keys never leave the server
|
||||
correct, wrong := 0, 0
|
||||
for i, q := range questions {
|
||||
if i >= len(req.Answers) || req.Answers[i] < 0 {
|
||||
wrong++
|
||||
continue
|
||||
}
|
||||
if req.Answers[i] >= len(q.Options) {
|
||||
wrong++
|
||||
continue
|
||||
}
|
||||
if req.Answers[i] == q.AnswerIndex {
|
||||
correct++
|
||||
} else {
|
||||
wrong++
|
||||
}
|
||||
}
|
||||
total := len(questions)
|
||||
passed := correct == total // strict: every answer must be correct
|
||||
|
||||
// record attempt
|
||||
attempt, err := s.store.CreateAttempt(r.Context(), u.ID, id, req.Answers)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت تلاش ممکن نشد")
|
||||
return
|
||||
}
|
||||
status := "failed"
|
||||
if passed {
|
||||
status = "passed"
|
||||
}
|
||||
if err := s.store.FinalizeAttempt(r.Context(), attempt.ID, correct, total, float64(correct)/float64(total)*100, status, wrong); err != nil {
|
||||
observability.L().Error("finalize attempt", "error", err, "attempt_id", attempt.ID)
|
||||
writeErr(w, http.StatusInternalServerError, "نهاییسازی تلاش ممکن نشد")
|
||||
return
|
||||
}
|
||||
|
||||
activeUser := u
|
||||
heartsLost := 0
|
||||
xpEarned := 0
|
||||
wasLocked := false
|
||||
|
||||
if passed {
|
||||
firstPass, claimErr := s.store.ClaimFirstQuizPass(r.Context(), u.ID, id)
|
||||
if claimErr != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت قبولی درس ممکن نشد")
|
||||
return
|
||||
}
|
||||
if firstPass {
|
||||
observability.Activity("lesson.passed", "user_id", u.ID, "lesson_id", id, "xp", lesson.XPReward)
|
||||
xpEarned = lesson.XPReward
|
||||
activeUser, err = s.rewardXP(r.Context(), u, xpEarned, "quiz", id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "اعطای امتیاز ممکن نشد")
|
||||
return
|
||||
}
|
||||
if cert, created, cerr := s.issueCertificateIfEligible(r.Context(), activeUser); cerr == nil && created && cert != nil {
|
||||
observability.Activity("certificate.issued", "user_id", u.ID, "public_id", cert.PublicID)
|
||||
go func() {
|
||||
_ = s.notify.Notify(context.Background(), u.ID, "progress", "certificate_issued",
|
||||
"گواهینامه آماده است", "دوره را به پایان رساندید. گواهینامه دیجیتال شما صادر شد.", "/c/"+cert.PublicID, nil)
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
s.notifyQuizPassed(u.ID, lesson.Title, id, activeUser.StreakCurrent)
|
||||
_ = s.notifyUnlockedLessons(u.ID, id)
|
||||
}()
|
||||
}
|
||||
} else if pg.PassedQuiz {
|
||||
// Already cleared — practice attempts do not burn hearts.
|
||||
activeUser = u
|
||||
} else {
|
||||
// consume hearts for wrong answers
|
||||
activeUser, heartsLost, err = s.hearts.ConsumeHearts(r.Context(), u, wrong)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "بهروزرسانی جانها ممکن نشد")
|
||||
return
|
||||
}
|
||||
if activeUser.IsLocked && !u.IsLocked {
|
||||
wasLocked = true
|
||||
observability.Lockouts.Inc()
|
||||
observability.Activity("account.locked", "user_id", u.ID, "lesson_id", id, "reason", "hearts")
|
||||
}
|
||||
go func(wasLocked bool) {
|
||||
if wasLocked {
|
||||
_ = s.notify.Notify(context.Background(), u.ID, "progress", "account_locked",
|
||||
"حساب محدود شد", "جانهای شما تمام شده است. دسترسی به حساب تا بررسی متصدی محدود میشود.", "/lockout", nil)
|
||||
} else if heartsLost > 0 {
|
||||
_ = s.notify.Notify(context.Background(), u.ID, "progress", "heart_lost",
|
||||
"یک جان از دست رفت", "یکی از جانهای شما کم شد. ادامه دهید!", "/unwrap", nil)
|
||||
}
|
||||
}(wasLocked)
|
||||
}
|
||||
|
||||
resultLabel := "fail"
|
||||
if passed {
|
||||
resultLabel = "pass"
|
||||
}
|
||||
observability.QuizSubmits.WithLabelValues(resultLabel).Inc()
|
||||
observability.Activity("quiz.submit",
|
||||
"user_id", u.ID,
|
||||
"lesson_id", id,
|
||||
"passed", passed,
|
||||
"correct", correct,
|
||||
"total", total,
|
||||
"hearts_lost", heartsLost,
|
||||
"hearts_left", activeUser.Hearts,
|
||||
"xp_earned", xpEarned,
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"passed": passed,
|
||||
"correctCount": correct,
|
||||
"total": total,
|
||||
"heartsLost": heartsLost,
|
||||
"heartsLeft": activeUser.Hearts,
|
||||
"xpEarned": xpEarned,
|
||||
"locked": activeUser.IsLocked,
|
||||
"wasLocked": wasLocked,
|
||||
"streak": activeUser.StreakCurrent,
|
||||
})
|
||||
}
|
||||
|
||||
// rewardXP applies XP atomically: user, xp_events ledger, weekly leaderboard (DB + Redis), streak.
|
||||
func (s *Server) rewardXP(ctx context.Context, u *models.User, amount int, source string, refID int64) (*models.User, error) {
|
||||
tx, err := s.store.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if err := s.store.AddXPEvent(ctx, tx, u.ID, amount, source, refID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.UpdateLeaderboardRow(ctx, service.ISOWeek(time.Now()), u.ID, amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.store.AddChallengeProgress(ctx, u.ID, amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.redis != nil {
|
||||
key := "lb:" + service.ISOWeek(time.Now())
|
||||
s.redis.ZIncrBy(ctx, key, float64(amount), strconv.FormatInt(u.ID, 10))
|
||||
s.redis.ZIncrBy(ctx, key, 0, strconv.FormatInt(u.ID, 10)) // ensure membership
|
||||
}
|
||||
user, err := s.store.AddUserXP(ctx, u.ID, amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := service.UpdateStreak(ctx, s.store, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Server) notifyQuizPassed(userID int64, lessonTitle string, lessonID int64, streak int) {
|
||||
body := "آفرین! درس «" + lessonTitle + "» را با موفقیت گذراندید."
|
||||
if streak > 0 && streak%7 == 0 {
|
||||
body = "آفرین! زنجیرهٔ فعالیت شما به " + strconv.Itoa(streak) + " روز رسید."
|
||||
}
|
||||
_ = s.notify.Notify(context.Background(), userID, "gamification", "quiz_passed",
|
||||
"آزمون را قبول شدید", body, "/cap?lesson="+strconv.FormatInt(lessonID, 10), nil)
|
||||
}
|
||||
|
||||
// notifyUnlockedLessons notifies about lessons whose prerequisite just got passed.
|
||||
func (s *Server) notifyUnlockedLessons(userID, lessonID int64) error {
|
||||
lessons, err := s.store.ListLessons(context.Background(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range lessons {
|
||||
if l.RequiresLessonID != nil && *l.RequiresLessonID == lessonID {
|
||||
_ = s.notify.Notify(context.Background(), userID, "progress", "lesson_unlocked",
|
||||
"درس جدید باز شد", "درس «"+l.Title+"» اکنون در دسترس است.", "/cap?lesson="+strconv.FormatInt(l.ID, 10), nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- quiz result endpoint ----
|
||||
|
||||
func (s *Server) handleQuizResult(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
|
||||
}
|
||||
at, err := s.store.LastAttempt(r.Context(), u.ID, id)
|
||||
if err != nil {
|
||||
if err == store.ErrNotFound {
|
||||
writeErr(w, http.StatusNotFound, "هنوز تلاشی ثبت نشده است")
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, "بارگذاری نتیجه ممکن نشد")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"correctCount": at.CorrectCount,
|
||||
"total": at.Total,
|
||||
"scorePct": at.ScorePct,
|
||||
"status": at.Status,
|
||||
"passedAt": at.PassedAt,
|
||||
"createdAt": at.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// handleRequestReset does not provide a self-service unlock path.
|
||||
func (s *Server) handleRequestReset(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusForbidden, "بازنشانی مسیر فقط با اقدام متصدی ممکن است")
|
||||
}
|
||||
|
||||
// handleRequestUnlock queues a staff review request (does not unlock the account).
|
||||
func (s *Server) handleRequestUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u.Role != models.RoleStudent {
|
||||
writeErr(w, http.StatusBadRequest, "فقط دانشجو میتواند درخواست رفع قفل بدهد")
|
||||
return
|
||||
}
|
||||
if !u.IsLocked {
|
||||
writeErr(w, http.StatusConflict, "حساب شما قفل نیست")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
_ = bodyJSON(r, &req)
|
||||
req.Note = strings.TrimSpace(req.Note)
|
||||
if len(req.Note) > 500 {
|
||||
writeErr(w, http.StatusBadRequest, "یادداشت نباید بیش از ۵۰۰ نویسه باشد")
|
||||
return
|
||||
}
|
||||
if existing, err := s.store.LatestOpenUnlockRequest(r.Context(), u.ID, 12*time.Hour); err == nil && existing != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"request": existing,
|
||||
"message": "درخواست قبلی شما هنوز در صف بررسی است",
|
||||
})
|
||||
return
|
||||
}
|
||||
ur, err := s.store.CreateUnlockRequest(r.Context(), u.ID, req.Note)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "ثبت درخواست ممکن نشد")
|
||||
return
|
||||
}
|
||||
staff, _ := s.store.ListMentors(r.Context())
|
||||
body := u.Name + " درخواست رفع قفل حساب داده است."
|
||||
if req.Note != "" {
|
||||
body += " یادداشت: " + req.Note
|
||||
}
|
||||
go func() {
|
||||
for _, m := range staff {
|
||||
_ = s.notify.Notify(context.Background(), m.ID, "mentor", "unlock_request",
|
||||
"درخواست رفع قفل", body, "/admin", map[string]any{"userId": u.ID, "requestId": ur.ID})
|
||||
}
|
||||
}()
|
||||
observability.Activity("account.unlock_requested", "user_id", u.ID, "request_id", ur.ID)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"ok": true,
|
||||
"request": ur,
|
||||
"message": "درخواست برای تیم ارسال شد. میتوانید همزمان با منتور هم گفتگو کنید.",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user