feat(backend): add domain store, learning services, and seed data

Persist LMS entities and implement hearts/watch rules plus curriculum bootstrap.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent f76c7a110b
commit 3ee609337c
15 changed files with 4076 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"pargar/backend/internal/models"
)
func (s *Store) CreateChallenge(ctx context.Context, c *models.Challenge) (int64, error) {
err := s.pool.QueryRow(ctx, `
INSERT INTO challenges (title, description, target_xp, starts_at, ends_at, is_active, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, created_at`,
c.Title, c.Description, c.TargetXP, c.StartsAt, c.EndsAt, c.IsActive, c.CreatedBy,
).Scan(&c.ID, &c.CreatedAt)
return c.ID, err
}
func (s *Store) UpdateChallenge(ctx context.Context, id int64, c *models.Challenge) error {
tag, err := s.pool.Exec(ctx, `
UPDATE challenges SET title=$1, description=$2, target_xp=$3, starts_at=$4, ends_at=$5, is_active=$6
WHERE id=$7`,
c.Title, c.Description, c.TargetXP, c.StartsAt, c.EndsAt, c.IsActive, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteChallenge(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM challenges WHERE id=$1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListChallengesAdmin(ctx context.Context) ([]models.Challenge, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, title, description, target_xp, starts_at, ends_at, is_active, created_at
FROM challenges ORDER BY starts_at DESC LIMIT 200`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanChallenges(rows)
}
// ListActiveChallengesForUser returns challenges currently in window (active + now in range) with progress.
func (s *Store) ListActiveChallengesForUser(ctx context.Context, userID int64) ([]models.Challenge, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.id, c.title, c.description, c.target_xp, c.starts_at, c.ends_at, c.is_active, c.created_at,
COALESCE(p.xp, 0) AS my_xp
FROM challenges c
LEFT JOIN challenge_progress p ON p.challenge_id = c.id AND p.user_id = $1
WHERE c.is_active AND c.starts_at <= now() AND c.ends_at >= now()
ORDER BY c.ends_at ASC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []models.Challenge
for rows.Next() {
var c models.Challenge
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.TargetXP, &c.StartsAt, &c.EndsAt, &c.IsActive, &c.CreatedAt, &c.MyXP); err != nil {
return nil, err
}
c.Completed = c.MyXP >= c.TargetXP
out = append(out, c)
}
if out == nil {
out = []models.Challenge{}
}
return out, rows.Err()
}
// AddChallengeProgress adds XP toward all active challenges in the current window.
func (s *Store) AddChallengeProgress(ctx context.Context, userID int64, amount int) error {
if amount <= 0 {
return nil
}
_, err := s.pool.Exec(ctx, `
INSERT INTO challenge_progress (challenge_id, user_id, xp, updated_at)
SELECT c.id, $1, $2, now()
FROM challenges c
WHERE c.is_active AND c.starts_at <= now() AND c.ends_at >= now()
ON CONFLICT (challenge_id, user_id) DO UPDATE
SET xp = challenge_progress.xp + EXCLUDED.xp,
updated_at = now()`, userID, amount)
return err
}
func scanChallenges(rows pgx.Rows) ([]models.Challenge, error) {
var out []models.Challenge
for rows.Next() {
var c models.Challenge
if err := rows.Scan(&c.ID, &c.Title, &c.Description, &c.TargetXP, &c.StartsAt, &c.EndsAt, &c.IsActive, &c.CreatedAt); err != nil {
return nil, err
}
out = append(out, c)
}
if out == nil {
out = []models.Challenge{}
}
return out, rows.Err()
}
func (s *Store) GetChallenge(ctx context.Context, id int64) (*models.Challenge, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, title, description, target_xp, starts_at, ends_at, is_active, created_at
FROM challenges WHERE id=$1`, id)
var c models.Challenge
err := row.Scan(&c.ID, &c.Title, &c.Description, &c.TargetXP, &c.StartsAt, &c.EndsAt, &c.IsActive, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &c, nil
}
// ChallengeWindowOK is a small helper for tests / validation.
func ChallengeWindowOK(starts, ends time.Time) bool {
return !starts.IsZero() && !ends.IsZero() && !ends.Before(starts)
}