Files
pargar/backend/internal/store/learning.go
T
ahp d9a2abb44f feat: release v0.1.3 chat, profiles, and media
Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
2026-10-09 19:05:59 +03:30

342 lines
11 KiB
Go

package store
import (
"context"
"time"
)
// LearningOverview is cohort-level learning analytics for staff.
type LearningOverview struct {
StudentsActive int64 `json:"studentsActive"`
StudentsLocked int64 `json:"studentsLocked"`
StudentsTotal int64 `json:"studentsTotal"`
LessonsTotal int64 `json:"lessonsTotal"`
Completions int64 `json:"completions"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
AvgWatchPct float64 `json:"avgWatchPct"`
XPAwarded7d int64 `json:"xpAwarded7d"`
StudentsStreaking int64 `json:"studentsStreaking"`
}
func (s *Store) LearningOverview(ctx context.Context) (*LearningOverview, error) {
o := &LearningOverview{}
if err := s.pool.QueryRow(ctx, `
SELECT
COUNT(*) FILTER (WHERE role='student'),
COUNT(*) FILTER (WHERE role='student' AND is_active),
COUNT(*) FILTER (WHERE role='student' AND is_locked)
FROM users`).Scan(&o.StudentsTotal, &o.StudentsActive, &o.StudentsLocked); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&o.LessonsTotal); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE passed_quiz`).Scan(&o.Completions); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `
SELECT
COUNT(*),
COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE created_at >= now() - interval '7 days'`).Scan(&o.Attempts7d, &o.Passes7d); err != nil {
return nil, err
}
if o.Attempts7d > 0 {
o.PassRate7d = float64(o.Passes7d) / float64(o.Attempts7d) * 100
}
_ = s.pool.QueryRow(ctx, `
SELECT COALESCE(AVG(watched_pct), 0)
FROM lesson_progress
WHERE watched_pct > 0 AND NOT passed_quiz`).Scan(&o.AvgWatchPct)
_ = s.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0)
FROM xp_events
WHERE created_at >= now() - interval '7 days'`).Scan(&o.XPAwarded7d)
_ = s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM users
WHERE role='student' AND streak_current > 0 AND is_active`).Scan(&o.StudentsStreaking)
return o, nil
}
// StudentLearningRow is a list row for the learning dashboard.
type StudentLearningRow struct {
ID int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
XP int `json:"xp"`
Hearts int `json:"hearts"`
StreakCurrent int `json:"streakCurrent"`
IsLocked bool `json:"isLocked"`
IsActive bool `json:"isActive"`
LessonsPassed int64 `json:"lessonsPassed"`
LessonsTotal int64 `json:"lessonsTotal"`
ProgressPct float64 `json:"progressPct"`
Attempts7d int64 `json:"attempts7d"`
Passes7d int64 `json:"passes7d"`
PassRate7d float64 `json:"passRate7d"`
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
}
func (s *Store) ListStudentLearning(ctx context.Context, q, filter, sort string, limit, offset int) ([]StudentLearningRow, int64, error) {
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100
}
if offset < 0 {
offset = 0
}
var lessonsTotal int64
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&lessonsTotal); err != nil {
return nil, 0, err
}
where := `u.role = 'student'
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')`
switch filter {
case "active":
where += ` AND u.is_active AND NOT u.is_locked`
case "locked":
where += ` AND u.is_locked`
}
order := `u.xp DESC, u.id ASC`
switch sort {
case "progress":
order = `COALESCE(p.passed, 0) DESC, u.id ASC`
case "streak":
order = `u.streak_current DESC, u.id ASC`
case "activity":
order = `u.last_activity_date DESC NULLS LAST, u.id ASC`
}
var total int64
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users u WHERE `+where, q).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.pool.Query(ctx, `
SELECT u.id, u.name, u.username, u.email, u.xp, u.hearts, u.streak_current,
u.is_locked, u.is_active, u.last_activity_date,
COALESCE(p.passed, 0),
COALESCE(a.attempts_7d, 0),
COALESCE(a.passes_7d, 0)
FROM users u
LEFT JOIN (
SELECT user_id, COUNT(*) FILTER (WHERE passed_quiz) AS passed
FROM lesson_progress GROUP BY user_id
) p ON p.user_id = u.id
LEFT JOIN (
SELECT user_id,
COUNT(*) AS attempts_7d,
COUNT(*) FILTER (WHERE status='passed') AS passes_7d
FROM quiz_attempts
WHERE created_at >= now() - interval '7 days'
GROUP BY user_id
) a ON a.user_id = u.id
WHERE `+where+`
ORDER BY `+order+`
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]StudentLearningRow, 0)
for rows.Next() {
var r StudentLearningRow
r.LessonsTotal = lessonsTotal
if err := rows.Scan(
&r.ID, &r.Name, &r.Username, &r.Email, &r.XP, &r.Hearts, &r.StreakCurrent,
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
); err != nil {
return nil, 0, err
}
if r.LessonsTotal > 0 {
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
}
if r.Attempts7d > 0 {
r.PassRate7d = float64(r.Passes7d) / float64(r.Attempts7d) * 100
}
out = append(out, r)
}
return out, total, rows.Err()
}
type LessonLearningDetail struct {
LessonID int64 `json:"lessonId"`
ChapterID int64 `json:"chapterId"`
ChapterTitle string `json:"chapterTitle"`
Title string `json:"title"`
SortOrder int `json:"sortOrder"`
WatchedPct float64 `json:"watchedPct"`
QuizUnlocked bool `json:"quizUnlocked"`
PassedQuiz bool `json:"passedQuiz"`
QuizCompletedAt *time.Time `json:"quizCompletedAt,omitempty"`
LastAttemptStatus string `json:"lastAttemptStatus,omitempty"`
LastAttemptScore float64 `json:"lastAttemptScore,omitempty"`
}
type AttemptLearningDetail struct {
ID int64 `json:"id"`
LessonID int64 `json:"lessonId"`
LessonTitle string `json:"lessonTitle"`
CorrectCount int `json:"correctCount"`
Total int `json:"total"`
ScorePct float64 `json:"scorePct"`
Status string `json:"status"`
HeartsLost int `json:"heartsLost"`
CreatedAt time.Time `json:"createdAt"`
}
type XPEventDetail struct {
ID int64 `json:"id"`
Amount int `json:"amount"`
Source string `json:"source"`
RefID int64 `json:"refId"`
CreatedAt time.Time `json:"createdAt"`
}
type WatchSessionDetail struct {
LessonID int64 `json:"lessonId"`
LessonTitle string `json:"lessonTitle"`
StartedAt time.Time `json:"startedAt"`
LastHeartbeatAt time.Time `json:"lastHeartbeatAt"`
LastPosition float64 `json:"lastPosition"`
}
type UserLearningDetail struct {
User StudentLearningRow `json:"user"`
Lessons []LessonLearningDetail `json:"lessons"`
Attempts []AttemptLearningDetail `json:"attempts"`
XPEvents []XPEventDetail `json:"xpEvents"`
Sessions []WatchSessionDetail `json:"sessions"`
}
func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLearningDetail, error) {
u, err := s.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
row := StudentLearningRow{
ID: u.ID, Name: u.Name, Username: u.Username, Email: u.Email,
XP: u.XP, Hearts: u.Hearts, StreakCurrent: u.StreakCurrent,
IsLocked: u.IsLocked, IsActive: u.IsActive, LastActivityAt: u.LastActivityDate,
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal); err != nil {
return nil, err
}
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed); err != nil {
return nil, err
}
if row.LessonsTotal > 0 {
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
}
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*), COUNT(*) FILTER (WHERE status='passed')
FROM quiz_attempts
WHERE user_id=$1 AND created_at >= now() - interval '7 days'`, userID).Scan(&row.Attempts7d, &row.Passes7d); err != nil {
return nil, err
}
if row.Attempts7d > 0 {
row.PassRate7d = float64(row.Passes7d) / float64(row.Attempts7d) * 100
}
detail := &UserLearningDetail{User: row, Lessons: []LessonLearningDetail{}, Attempts: []AttemptLearningDetail{}, XPEvents: []XPEventDetail{}, Sessions: []WatchSessionDetail{}}
lrows, err := s.pool.Query(ctx, `
SELECT l.id, l.chapter_id, c.title, l.title, l.sort_order,
COALESCE(p.watched_pct, 0), COALESCE(p.quiz_unlocked, false), COALESCE(p.passed_quiz, false), p.quiz_completed_at,
COALESCE(la.status::text, ''), COALESCE(la.score_pct, 0)
FROM lessons l
JOIN chapters c ON c.id = l.chapter_id
LEFT JOIN lesson_progress p ON p.lesson_id = l.id AND p.user_id = $1
LEFT JOIN LATERAL (
SELECT status, score_pct FROM quiz_attempts
WHERE user_id=$1 AND lesson_id=l.id
ORDER BY created_at DESC LIMIT 1
) la ON true
WHERE l.is_active
ORDER BY c.sort_order, l.sort_order, l.id`, userID)
if err != nil {
return nil, err
}
defer lrows.Close()
for lrows.Next() {
var d LessonLearningDetail
if err := lrows.Scan(
&d.LessonID, &d.ChapterID, &d.ChapterTitle, &d.Title, &d.SortOrder,
&d.WatchedPct, &d.QuizUnlocked, &d.PassedQuiz, &d.QuizCompletedAt,
&d.LastAttemptStatus, &d.LastAttemptScore,
); err != nil {
return nil, err
}
detail.Lessons = append(detail.Lessons, d)
}
if err := lrows.Err(); err != nil {
return nil, err
}
arows, err := s.pool.Query(ctx, `
SELECT a.id, a.lesson_id, l.title, a.correct_count, a.total, a.score_pct, a.status, a.hearts_lost, a.created_at
FROM quiz_attempts a
JOIN lessons l ON l.id = a.lesson_id
WHERE a.user_id = $1
ORDER BY a.created_at DESC
LIMIT 50`, userID)
if err != nil {
return nil, err
}
defer arows.Close()
for arows.Next() {
var a AttemptLearningDetail
if err := arows.Scan(&a.ID, &a.LessonID, &a.LessonTitle, &a.CorrectCount, &a.Total, &a.ScorePct, &a.Status, &a.HeartsLost, &a.CreatedAt); err != nil {
return nil, err
}
detail.Attempts = append(detail.Attempts, a)
}
if err := arows.Err(); err != nil {
return nil, err
}
xrows, err := s.pool.Query(ctx, `
SELECT id, amount, source, COALESCE(ref_id, 0), created_at
FROM xp_events WHERE user_id=$1
ORDER BY created_at DESC LIMIT 40`, userID)
if err != nil {
return nil, err
}
defer xrows.Close()
for xrows.Next() {
var x XPEventDetail
if err := xrows.Scan(&x.ID, &x.Amount, &x.Source, &x.RefID, &x.CreatedAt); err != nil {
return nil, err
}
detail.XPEvents = append(detail.XPEvents, x)
}
if err := xrows.Err(); err != nil {
return nil, err
}
srows, err := s.pool.Query(ctx, `
SELECT ws.lesson_id, l.title, ws.started_at, ws.last_heartbeat_at, ws.last_position
FROM watch_sessions ws
JOIN lessons l ON l.id = ws.lesson_id
WHERE ws.user_id = $1
ORDER BY ws.last_heartbeat_at DESC
LIMIT 20`, userID)
if err != nil {
return nil, err
}
defer srows.Close()
for srows.Next() {
var w WatchSessionDetail
if err := srows.Scan(&w.LessonID, &w.LessonTitle, &w.StartedAt, &w.LastHeartbeatAt, &w.LastPosition); err != nil {
return nil, err
}
detail.Sessions = append(detail.Sessions, w)
}
return detail, srows.Err()
}