feat: implement 8 features across full stack

- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
    deterministic tie-breaking (xp DESC, user_id ASC)
  - Mentor chat: md: breakpoints, safe-area-inset-bottom padding
  - Learning progress: server-side progressPct, curriculum expansion safety
  - Physical orders: hard-disabled with 410 Gone
  - Certificate approval: pending/issued flow, admin issue/revoke endpoints,
    certificate_requires_approval setting (migration 0031)
  - Draft/publish: is_published on lessons and chapters, admin publish UI
    (migration 0030)
  - Video panel: static thumbnails (no preload), client-side pagination
  - Rich editor: TipTap + tiptap-markdown for lesson descriptions,
    marked + DOMPurify for SafeMarkdown rendering
This commit is contained in:
ahp
2026-10-18 10:14:22 +03:30
parent 75e2892e36
commit 03610bc3d3
23 changed files with 1641 additions and 474 deletions
+107 -26
View File
@@ -618,7 +618,7 @@ func IsUniqueViolation(err error) bool {
func (s *Store) CountActiveLessons(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active`).Scan(&n)
err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM lessons WHERE is_active AND is_published`).Scan(&n)
return n, err
}
@@ -627,11 +627,16 @@ func (s *Store) CountPassedActiveLessons(ctx context.Context, userID int64) (int
err := s.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l ON l.id = lp.lesson_id
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active`, userID).Scan(&n)
WHERE lp.user_id=$1 AND lp.passed_quiz AND l.is_active AND l.is_published`, userID).Scan(&n)
return n, err
}
func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool, error) {
// An issued (non-revoked) certificate is permanent proof of completion regardless
// of subsequent curriculum changes (e.g. new lessons added after the student finished).
if cert, err := s.GetCertificateByUser(ctx, userID); err == nil && cert.RevokedAt == nil {
return true, nil
}
active, err := s.CountActiveLessons(ctx)
if err != nil {
return false, err
@@ -648,42 +653,106 @@ func (s *Store) HasCompletedCurriculum(ctx context.Context, userID int64) (bool,
func (s *Store) GetCertificateByUser(ctx context.Context, userID int64) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE user_id=$1`, userID)
return scanCertificate(row)
}
func (s *Store) GetCertificateByPublicID(ctx context.Context, publicID string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, user_id, public_id, full_name, issued_at, revoked_at
FROM certificates WHERE public_id=$1`, publicID)
SELECT id, user_id, public_id, full_name, issued_at, revoked_at, status
FROM certificates WHERE public_id=$1 AND status='issued'`, publicID)
return scanCertificate(row)
}
func scanCertificate(row rowScanner) (*models.Certificate, error) {
var c models.Certificate
err := row.Scan(&c.ID, &c.UserID, &c.PublicID, &c.FullName, &c.IssuedAt, &c.RevokedAt)
var publicID *string
err := row.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if publicID != nil {
c.PublicID = *publicID
}
return &c, nil
}
func (s *Store) CreateCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
INSERT INTO certificates (user_id, public_id, full_name)
VALUES ($1, $2, $3)
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, $2, $3, 'issued')
ON CONFLICT (user_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
public_id = EXCLUDED.public_id,
status = 'issued',
revoked_at = NULL
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at`,
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
userID, publicID, fullName)
return scanCertificate(row)
}
func (s *Store) CreatePendingCertificate(ctx context.Context, userID int64, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
INSERT INTO certificates (user_id, public_id, full_name, status)
VALUES ($1, NULL, $2, 'pending')
ON CONFLICT (user_id) DO NOTHING
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
userID, fullName)
cert, err := scanCertificate(row)
if errors.Is(err, ErrNotFound) {
return s.GetCertificateByUser(ctx, userID)
}
return cert, err
}
func (s *Store) IssuePendingCertificate(ctx context.Context, userID int64, publicID, fullName string) (*models.Certificate, error) {
row := s.pool.QueryRow(ctx, `
UPDATE certificates SET
public_id = $1,
full_name = $2,
status = 'issued',
revoked_at = NULL,
issued_at = NOW()
WHERE user_id = $3
RETURNING id, user_id, public_id, full_name, issued_at, revoked_at, status`,
publicID, fullName, userID)
return scanCertificate(row)
}
func (s *Store) RevokeCertificate(ctx context.Context, userID int64) error {
_, err := s.pool.Exec(ctx, `UPDATE certificates SET revoked_at=NOW() WHERE user_id=$1`, userID)
return err
}
func (s *Store) ListCertificatesByStatus(ctx context.Context, status string) ([]models.Certificate, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.user_id, c.public_id, c.full_name, c.issued_at, c.revoked_at, c.status
FROM certificates c
WHERE c.status=$1 AND c.revoked_at IS NULL
ORDER BY c.issued_at DESC`, status)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Certificate
for rows.Next() {
var c models.Certificate
var publicID *string
if err := rows.Scan(&c.ID, &c.UserID, &publicID, &c.FullName, &c.IssuedAt, &c.RevokedAt, &c.Status); err != nil {
return nil, err
}
if publicID != nil {
c.PublicID = *publicID
}
out = append(out, c)
}
return out, rows.Err()
}
const physicalOrderCols = `id, certificate_id, user_id, status, note,
recipient_name, phone, address, city, postal_code, tracking_code,
window_ends_at, created_at, updated_at`
@@ -879,16 +948,22 @@ func (s *Store) DeleteUser(ctx context.Context, id int64) error {
// ---- Chapters / Lessons / Questions ----
func (s *Store) CreateChapter(ctx context.Context, c *models.Chapter) (int64, error) {
c.IsPublished = false
err := s.pool.QueryRow(ctx, `
INSERT INTO chapters (title, description, icon, sort_order) VALUES ($1,$2,$3,$4) RETURNING id`,
c.Title, c.Description, c.Icon, c.SortOrder).Scan(&c.ID)
INSERT INTO chapters (title, description, icon, sort_order, is_published) VALUES ($1,$2,$3,$4,$5) RETURNING id`,
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished).Scan(&c.ID)
return c.ID, err
}
func (s *Store) UpdateChapter(ctx context.Context, id int64, c *models.Chapter) error {
_, err := s.pool.Exec(ctx, `
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4 WHERE id=$5`,
c.Title, c.Description, c.Icon, c.SortOrder, id)
UPDATE chapters SET title=$1, description=$2, icon=$3, sort_order=$4, is_published=$5 WHERE id=$6`,
c.Title, c.Description, c.Icon, c.SortOrder, c.IsPublished, id)
return err
}
func (s *Store) PublishChapter(ctx context.Context, id int64, publish bool) error {
_, err := s.pool.Exec(ctx, `UPDATE chapters SET is_published=$1 WHERE id=$2`, publish, id)
return err
}
@@ -898,7 +973,7 @@ func (s *Store) DeleteChapter(ctx context.Context, id int64) error {
}
func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order FROM chapters ORDER BY sort_order, id`)
rows, err := s.pool.Query(ctx, `SELECT id, title, description, icon, sort_order, is_published FROM chapters ORDER BY sort_order, id`)
if err != nil {
return nil, err
}
@@ -906,7 +981,7 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
var out []models.Chapter
for rows.Next() {
var c models.Chapter
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder); err != nil {
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.Icon, &c.SortOrder, &c.IsPublished); err != nil {
return nil, err
}
out = append(out, c)
@@ -915,22 +990,28 @@ func (s *Store) ListChapters(ctx context.Context) ([]models.Chapter, error) {
}
func (s *Store) CreateLesson(ctx context.Context, l *models.Lesson) (int64, error) {
l.IsPublished = false
err := s.pool.QueryRow(ctx, `
INSERT INTO lessons (chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id`,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive).Scan(&l.ID)
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished).Scan(&l.ID)
return l.ID, err
}
func (s *Store) UpdateLesson(ctx context.Context, id int64, l *models.Lesson) error {
_, err := s.pool.Exec(ctx, `
UPDATE lessons SET chapter_id=$1, title=$2, description=$3, video_key=$4, duration_seconds=$5,
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12
WHERE id=$13`,
completion_threshold_pct=$6, requires_lesson_id=$7, x=$8, y=$9, sort_order=$10, xp_reward=$11, is_active=$12, is_published=$13
WHERE id=$14`,
l.ChapterID, l.Title, l.Description, l.VideoKey, l.DurationSeconds,
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, id)
l.CompletionThresholdPct, l.RequiresLessonID, l.X, l.Y, l.SortOrder, l.XPReward, l.IsActive, l.IsPublished, id)
return err
}
func (s *Store) PublishLesson(ctx context.Context, id int64, publish bool) error {
_, err := s.pool.Exec(ctx, `UPDATE lessons SET is_published=$1 WHERE id=$2`, publish, id)
return err
}
@@ -942,11 +1023,11 @@ func (s *Store) DeleteLesson(ctx context.Context, id int64) error {
func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published
FROM lessons WHERE id=$1`, id)
var l models.Lesson
err := row.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive)
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -955,9 +1036,9 @@ func (s *Store) GetLesson(ctx context.Context, id int64) (*models.Lesson, error)
func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models.Lesson, error) {
q := `SELECT id, chapter_id, title, description, video_key, duration_seconds,
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active FROM lessons`
completion_threshold_pct, requires_lesson_id, x, y, sort_order, xp_reward, is_active, is_published FROM lessons`
if !includeInactive {
q += ` WHERE is_active`
q += ` WHERE is_active AND is_published`
}
q += ` ORDER BY chapter_id, sort_order, id`
rows, err := s.pool.Query(ctx, q)
@@ -969,7 +1050,7 @@ func (s *Store) ListLessons(ctx context.Context, includeInactive bool) ([]models
for rows.Next() {
var l models.Lesson
if err := rows.Scan(&l.ID, &l.ChapterID, &l.Title, &l.Description, &l.VideoKey, &l.DurationSeconds,
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive); err != nil {
&l.CompletionThresholdPct, &l.RequiresLessonID, &l.X, &l.Y, &l.SortOrder, &l.XPReward, &l.IsActive, &l.IsPublished); err != nil {
return nil, err
}
out = append(out, l)