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.
This commit is contained in:
@@ -146,7 +146,7 @@ func (s *Store) CreateRegistrationInvite(
|
||||
return scanInvite(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
|
||||
func (s *Store) ListRegistrationInvites(ctx context.Context, query, status string, limit, offset int) ([]models.RegistrationInvite, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
@@ -157,18 +157,21 @@ func (s *Store) ListRegistrationInvites(ctx context.Context, query string, limit
|
||||
offset = 0
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
status = strings.TrimSpace(status)
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM registration_invites
|
||||
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')`, query).Scan(&total); err != nil {
|
||||
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
||||
AND ($2 = '' OR status = $2)`, query, status).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+inviteCols+`
|
||||
FROM registration_invites
|
||||
WHERE ($1 = '' OR COALESCE(label, '') ILIKE '%'||$1||'%')
|
||||
AND ($2 = '' OR status = $2)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, query, limit, offset)
|
||||
LIMIT $3 OFFSET $4`, query, status, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
|
||||
// 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"`
|
||||
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) {
|
||||
@@ -81,10 +81,40 @@ type StudentLearningRow struct {
|
||||
LastActivityAt *time.Time `json:"lastActivityAt,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLearningRow, error) {
|
||||
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, err
|
||||
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,
|
||||
@@ -105,12 +135,11 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
|
||||
WHERE created_at >= now() - interval '7 days'
|
||||
GROUP BY user_id
|
||||
) a ON a.user_id = u.id
|
||||
WHERE u.role = 'student'
|
||||
AND ($1 = '' OR u.name ILIKE '%'||$1||'%' OR u.username ILIKE '%'||$1||'%' OR u.email ILIKE '%'||$1||'%')
|
||||
ORDER BY u.xp DESC, u.id ASC
|
||||
LIMIT 500`, q)
|
||||
WHERE `+where+`
|
||||
ORDER BY `+order+`
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StudentLearningRow, 0)
|
||||
@@ -122,7 +151,7 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
|
||||
&r.IsLocked, &r.IsActive, &r.LastActivityAt,
|
||||
&r.LessonsPassed, &r.Attempts7d, &r.Passes7d,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
if r.LessonsTotal > 0 {
|
||||
r.ProgressPct = float64(r.LessonsPassed) / float64(r.LessonsTotal) * 100
|
||||
@@ -132,21 +161,21 @@ func (s *Store) ListStudentLearning(ctx context.Context, q string) ([]StudentLea
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
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"`
|
||||
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 {
|
||||
@@ -178,11 +207,11 @@ type WatchSessionDetail struct {
|
||||
}
|
||||
|
||||
type UserLearningDetail struct {
|
||||
User StudentLearningRow `json:"user"`
|
||||
Lessons []LessonLearningDetail `json:"lessons"`
|
||||
Attempts []AttemptLearningDetail `json:"attempts"`
|
||||
XPEvents []XPEventDetail `json:"xpEvents"`
|
||||
Sessions []WatchSessionDetail `json:"sessions"`
|
||||
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) {
|
||||
@@ -190,30 +219,28 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := s.ListStudentLearning(ctx, "")
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
var row StudentLearningRow
|
||||
found := false
|
||||
for _, r := range list {
|
||||
if r.ID == userID {
|
||||
row = r
|
||||
found = true
|
||||
break
|
||||
}
|
||||
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 !found {
|
||||
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,
|
||||
}
|
||||
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons WHERE is_active`).Scan(&row.LessonsTotal)
|
||||
_ = s.pool.QueryRow(ctx, `SELECT count(*) FROM lesson_progress WHERE user_id=$1 AND passed_quiz`, userID).Scan(&row.LessonsPassed)
|
||||
if row.LessonsTotal > 0 {
|
||||
row.ProgressPct = float64(row.LessonsPassed) / float64(row.LessonsTotal) * 100
|
||||
}
|
||||
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{}}
|
||||
@@ -221,7 +248,7 @@ func (s *Store) UserLearningDetail(ctx context.Context, userID int64) (*UserLear
|
||||
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, ''), COALESCE(la.score_pct, 0)
|
||||
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
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestLearningOverviewSmoke(t *testing.T) {
|
||||
if o == nil {
|
||||
t.Fatal("nil overview")
|
||||
}
|
||||
students, err := st.ListStudentLearning(ctx, "")
|
||||
students, _, err := st.ListStudentLearning(ctx, "", "", "xp", 10, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -504,7 +504,26 @@ func (s *Store) GetPhysicalOrder(ctx context.Context, id int64) (*models.Certifi
|
||||
return scanPhysicalOrder(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models.CertificatePhysicalOrder, error) {
|
||||
func (s *Store) ListPhysicalOrders(ctx context.Context, status string, limit, offset int) ([]models.CertificatePhysicalOrder, int64, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
where := ""
|
||||
args := []any{}
|
||||
if status != "" {
|
||||
where = ` WHERE o.status=$1`
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
if err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM certificate_physical_orders o`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `
|
||||
SELECT o.id, o.certificate_id, o.user_id, o.status, o.note,
|
||||
o.recipient_name, o.phone, o.address, o.city, o.postal_code, o.tracking_code,
|
||||
@@ -512,16 +531,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
|
||||
u.name, c.public_id
|
||||
FROM certificate_physical_orders o
|
||||
JOIN users u ON u.id = o.user_id
|
||||
JOIN certificates c ON c.id = o.certificate_id`
|
||||
args := []any{}
|
||||
if status != "" {
|
||||
q += ` WHERE o.status=$1`
|
||||
args = append(args, status)
|
||||
}
|
||||
q += ` ORDER BY o.created_at DESC LIMIT 200`
|
||||
JOIN certificates c ON c.id = o.certificate_id` + where + ` ORDER BY o.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.CertificatePhysicalOrder, 0)
|
||||
@@ -530,11 +544,11 @@ func (s *Store) ListPhysicalOrders(ctx context.Context, status string) ([]models
|
||||
if err := rows.Scan(&o.ID, &o.CertificateID, &o.UserID, &o.Status, &o.Note,
|
||||
&o.RecipientName, &o.Phone, &o.Address, &o.City, &o.PostalCode, &o.TrackingCode,
|
||||
&o.WindowEndsAt, &o.CreatedAt, &o.UpdatedAt, &o.UserName, &o.PublicID); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePhysicalOrder(ctx context.Context, id int64, status models.PhysicalOrderStatus, note, tracking string) (*models.CertificatePhysicalOrder, error) {
|
||||
@@ -1069,7 +1083,7 @@ func (s *Store) AddChatMessage(ctx context.Context, userID, mentorID int64, send
|
||||
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, reply_to, attachment_type, attachment_url, attachment_name, attachment_size)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||
userID, mentorID, senderRole.String(), body, replyTo, attType, attURL, attName, attSize)
|
||||
return scanMessage(row)
|
||||
}
|
||||
@@ -1094,7 +1108,7 @@ func (s *Store) ImportChatMessage(ctx context.Context, userID, mentorID int64, s
|
||||
INSERT INTO chat_messages (user_id, mentor_id, sender_role, body, attachment_type, attachment_url, attachment_name, attachment_size, created_at, pinned_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||
userID, mentorID, senderRole.String(), body, attType, attURL, attName, attSize, createdAt, pinnedAt)
|
||||
return scanMessage(row)
|
||||
}
|
||||
@@ -1138,6 +1152,48 @@ func (s *Store) DeleteChatMessage(ctx context.Context, meID, partnerID, msgID in
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
// ReplaceChatAttachment swaps the photo, video, or voice on one of my messages and marks it edited.
|
||||
// The new type must match the current attachment.
|
||||
func (s *Store) ReplaceChatAttachment(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body, attType, attURL, attName string, attSize int64) (*models.ChatMessage, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE chat_messages
|
||||
SET body=$1, edited_at=now(),
|
||||
attachment_type=$2, attachment_url=$3, attachment_name=$4, attachment_size=$5
|
||||
WHERE id=$6
|
||||
AND ((user_id=$7 AND mentor_id=$8) OR (user_id=$8 AND mentor_id=$7))
|
||||
AND sender_role=$9
|
||||
AND (user_id=$7 OR mentor_id=$7)
|
||||
AND attachment_type=$2
|
||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||
body, attType, attURL, attName, attSize, msgID, meID, partnerID, meRole.String())
|
||||
m, err := scanMessage(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// EditChatMessage replaces the body of one of my own messages in this conversation.
|
||||
func (s *Store) EditChatMessage(ctx context.Context, meID, partnerID, msgID int64, meRole models.Role, body string) (*models.ChatMessage, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE chat_messages
|
||||
SET body=$1, edited_at=now()
|
||||
WHERE id=$2
|
||||
AND ((user_id=$3 AND mentor_id=$4) OR (user_id=$4 AND mentor_id=$3))
|
||||
AND sender_role=$5
|
||||
AND (user_id=$3 OR mentor_id=$3)
|
||||
AND ($1 <> '' OR attachment_url IS NOT NULL)
|
||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`,
|
||||
body, msgID, meID, partnerID, meRole.String())
|
||||
m, err := scanMessage(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
const maxPinnedMessages = 3
|
||||
|
||||
// TogglePinChatMessage pins or unpins a message. If already at the pin limit,
|
||||
@@ -1160,7 +1216,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
|
||||
out := s.pool.QueryRow(ctx, `
|
||||
UPDATE chat_messages SET pinned_at=NULL WHERE id=$1
|
||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
|
||||
return scanMessage(out)
|
||||
}
|
||||
var n int
|
||||
@@ -1188,7 +1244,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
|
||||
out := s.pool.QueryRow(ctx, `
|
||||
UPDATE chat_messages SET pinned_at=now() WHERE id=$1
|
||||
RETURNING id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at`, msgID)
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at`, msgID)
|
||||
return scanMessage(out)
|
||||
}
|
||||
|
||||
@@ -1196,7 +1252,7 @@ func (s *Store) TogglePinChatMessage(ctx context.Context, meID, partnerID, msgID
|
||||
func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int64) ([]models.ChatMessage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||
FROM chat_messages
|
||||
WHERE pinned_at IS NOT NULL
|
||||
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||
@@ -1222,7 +1278,7 @@ func (s *Store) ListPinnedChatMessages(ctx context.Context, userID, mentorID int
|
||||
func (s *Store) ListChatMessages(ctx context.Context, userID, mentorID, before, limit int64) ([]models.ChatMessage, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||
FROM chat_messages
|
||||
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||
AND ($3 = 0 OR id < $3)
|
||||
@@ -1270,7 +1326,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
|
||||
|
||||
older, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||
FROM chat_messages
|
||||
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||
AND id <= $3
|
||||
@@ -1302,7 +1358,7 @@ func (s *Store) ListChatMessagesAround(ctx context.Context, userID, partnerID, a
|
||||
|
||||
newer, err := s.pool.Query(ctx, `
|
||||
SELECT id, user_id, mentor_id, sender_role, body, read_at, created_at, reply_to,
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at
|
||||
attachment_type, attachment_url, attachment_name, attachment_size, pinned_at, edited_at
|
||||
FROM chat_messages
|
||||
WHERE ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))
|
||||
AND id > $3
|
||||
@@ -1345,7 +1401,7 @@ func scanMessage(row messageScanner) (*models.ChatMessage, error) {
|
||||
var attSize *int64
|
||||
if err := row.Scan(
|
||||
&m.ID, &m.UserID, &m.MentorID, &m.SenderRole, &m.Body, &m.ReadAt, &m.CreatedAt, &m.ReplyTo,
|
||||
&attType, &attURL, &attName, &attSize, &m.PinnedAt,
|
||||
&attType, &attURL, &attName, &attSize, &m.PinnedAt, &m.EditedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1372,11 +1428,20 @@ func derefInt(p *int64) int64 {
|
||||
return *p
|
||||
}
|
||||
|
||||
func (s *Store) MarkChatRead(ctx context.Context, userID, mentorID int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE chat_messages SET read_at=now() WHERE (user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1)`,
|
||||
userID, mentorID)
|
||||
return err
|
||||
// MarkChatRead stamps messages the other person sent. Opening the thread must not
|
||||
// mark my own messages as seen.
|
||||
func (s *Store) MarkChatRead(ctx context.Context, meID, partnerID int64, meRole models.Role) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE chat_messages
|
||||
SET read_at=now()
|
||||
WHERE read_at IS NULL
|
||||
AND sender_role <> $3
|
||||
AND ((user_id=$1 AND mentor_id=$2) OR (user_id=$2 AND mentor_id=$1))`,
|
||||
meID, partnerID, meRole.String())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// MessageInConversation reports whether a message belongs to a given pair.
|
||||
@@ -1743,7 +1808,7 @@ func (s *Store) AllActiveUserIDs(ctx context.Context) ([]int64, error) {
|
||||
|
||||
func (s *Store) Stats(ctx context.Context) (map[string]any, error) {
|
||||
var users, lessons, questions, events, completions int64
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&users); err != nil {
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE role='student' AND is_active`).Scan(&users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM lessons`).Scan(&lessons); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user