136 lines
4.1 KiB
Go
136 lines
4.1 KiB
Go
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)
|
||
|
|
}
|