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