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

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
}