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:
+107
-26
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user