Keep read receipts for the other person only, and let photos, voice, and files stay cached until they are actually replaced.
342 lines
11 KiB
Go
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()
|
|
}
|