You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
201 lines
6.0 KiB
Go
201 lines
6.0 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/denisovdennis/autohero/internal/model"
|
|
)
|
|
|
|
// DailyTaskStore handles daily/weekly task operations against PostgreSQL.
|
|
type DailyTaskStore struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewDailyTaskStore creates a new DailyTaskStore backed by the given connection pool.
|
|
func NewDailyTaskStore(pool *pgxpool.Pool) *DailyTaskStore {
|
|
return &DailyTaskStore{pool: pool}
|
|
}
|
|
|
|
// periodStart returns the start of the current period for a given period type.
|
|
// Daily: start of today (UTC). Weekly: start of this Monday (UTC).
|
|
func periodStart(now time.Time, period string) time.Time {
|
|
t := now.UTC()
|
|
today := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
|
if period == "weekly" {
|
|
// Go back to Monday.
|
|
weekday := int(today.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7 // Sunday -> 7
|
|
}
|
|
return today.AddDate(0, 0, -(weekday - 1))
|
|
}
|
|
return today
|
|
}
|
|
|
|
// EnsureHeroTasks creates task rows for the current period if they don't already exist.
|
|
// Called lazily when the hero checks their tasks.
|
|
func (s *DailyTaskStore) EnsureHeroTasks(ctx context.Context, heroID int64, now time.Time) error {
|
|
// Load all task definitions.
|
|
rows, err := s.pool.Query(ctx, `SELECT id, period FROM daily_tasks`)
|
|
if err != nil {
|
|
return fmt.Errorf("ensure hero tasks list: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type taskDef struct {
|
|
id string
|
|
period string
|
|
}
|
|
var tasks []taskDef
|
|
for rows.Next() {
|
|
var t taskDef
|
|
if err := rows.Scan(&t.id, &t.period); err != nil {
|
|
return fmt.Errorf("scan task def: %w", err)
|
|
}
|
|
tasks = append(tasks, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("task defs rows: %w", err)
|
|
}
|
|
|
|
for _, t := range tasks {
|
|
ps := periodStart(now, t.period)
|
|
_, err := s.pool.Exec(ctx, `
|
|
INSERT INTO hero_daily_tasks (hero_id, task_id, progress, completed, claimed, period_start)
|
|
VALUES ($1, $2, 0, false, false, $3)
|
|
ON CONFLICT DO NOTHING
|
|
`, heroID, t.id, ps)
|
|
if err != nil {
|
|
return fmt.Errorf("ensure task %s: %w", t.id, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListHeroTasks returns current daily and weekly tasks with progress for a hero.
|
|
// Only returns tasks for the current period (daily = today, weekly = this week).
|
|
func (s *DailyTaskStore) ListHeroTasks(ctx context.Context, heroID int64) ([]model.HeroDailyTask, error) {
|
|
now := time.Now().UTC()
|
|
dailyStart := periodStart(now, "daily")
|
|
weeklyStart := periodStart(now, "weekly")
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT hdt.hero_id, hdt.task_id, hdt.progress, hdt.completed, hdt.claimed, hdt.period_start,
|
|
dt.id, dt.title, dt.description, dt.objective_type, dt.objective_count,
|
|
dt.reward_type, dt.reward_amount, dt.period
|
|
FROM hero_daily_tasks hdt
|
|
JOIN daily_tasks dt ON hdt.task_id = dt.id
|
|
WHERE hdt.hero_id = $1
|
|
AND (
|
|
(dt.period = 'daily' AND hdt.period_start = $2)
|
|
OR
|
|
(dt.period = 'weekly' AND hdt.period_start = $3)
|
|
)
|
|
ORDER BY dt.period ASC, dt.id ASC
|
|
`, heroID, dailyStart, weeklyStart)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list hero tasks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tasks []model.HeroDailyTask
|
|
for rows.Next() {
|
|
var ht model.HeroDailyTask
|
|
var dt model.DailyTask
|
|
if err := rows.Scan(
|
|
&ht.HeroID, &ht.TaskID, &ht.Progress, &ht.Completed, &ht.Claimed, &ht.PeriodStart,
|
|
&dt.ID, &dt.Title, &dt.Description, &dt.ObjectiveType, &dt.ObjectiveCount,
|
|
&dt.RewardType, &dt.RewardAmount, &dt.Period,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan hero task: %w", err)
|
|
}
|
|
ht.Task = &dt
|
|
tasks = append(tasks, ht)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("hero tasks rows: %w", err)
|
|
}
|
|
if tasks == nil {
|
|
tasks = []model.HeroDailyTask{}
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
// IncrementTaskProgress increments progress for all matching uncompleted tasks
|
|
// in the current period. Automatically marks tasks as completed when the objective
|
|
// count is reached.
|
|
func (s *DailyTaskStore) IncrementTaskProgress(ctx context.Context, heroID int64, objectiveType string, delta int) error {
|
|
if delta <= 0 {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
dailyStart := periodStart(now, "daily")
|
|
weeklyStart := periodStart(now, "weekly")
|
|
|
|
_, err := s.pool.Exec(ctx, `
|
|
UPDATE hero_daily_tasks hdt
|
|
SET progress = LEAST(hdt.progress + $3, dt.objective_count),
|
|
completed = CASE WHEN hdt.progress + $3 >= dt.objective_count THEN true ELSE hdt.completed END
|
|
FROM daily_tasks dt
|
|
WHERE hdt.task_id = dt.id
|
|
AND hdt.hero_id = $1
|
|
AND hdt.completed = false
|
|
AND dt.objective_type = $2
|
|
AND (
|
|
(dt.period = 'daily' AND hdt.period_start = $4)
|
|
OR
|
|
(dt.period = 'weekly' AND hdt.period_start = $5)
|
|
)
|
|
`, heroID, objectiveType, delta, dailyStart, weeklyStart)
|
|
if err != nil {
|
|
return fmt.Errorf("increment task progress: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClaimTask marks a completed task as claimed and returns the reward.
|
|
// Returns an error if the task is not completed or already claimed.
|
|
func (s *DailyTaskStore) ClaimTask(ctx context.Context, heroID int64, taskID string) (*model.DailyTaskReward, error) {
|
|
now := time.Now().UTC()
|
|
dailyStart := periodStart(now, "daily")
|
|
weeklyStart := periodStart(now, "weekly")
|
|
|
|
var rewardType string
|
|
var rewardAmount int
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
UPDATE hero_daily_tasks hdt
|
|
SET claimed = true
|
|
FROM daily_tasks dt
|
|
WHERE hdt.task_id = dt.id
|
|
AND hdt.hero_id = $1
|
|
AND hdt.task_id = $2
|
|
AND hdt.completed = true
|
|
AND hdt.claimed = false
|
|
AND (
|
|
(dt.period = 'daily' AND hdt.period_start = $3)
|
|
OR
|
|
(dt.period = 'weekly' AND hdt.period_start = $4)
|
|
)
|
|
RETURNING dt.reward_type, dt.reward_amount
|
|
`, heroID, taskID, dailyStart, weeklyStart).Scan(&rewardType, &rewardAmount)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, fmt.Errorf("task not found, not completed, or already claimed")
|
|
}
|
|
return nil, fmt.Errorf("claim task: %w", err)
|
|
}
|
|
|
|
return &model.DailyTaskReward{
|
|
RewardType: rewardType,
|
|
RewardAmount: rewardAmount,
|
|
}, nil
|
|
}
|