- GameRepo: EnsureCategoryAchievement, RemoveCategoryAchievementIfNotEarned, ListCategoriesWithBudget, GetMonthlySpendByCategory - GameService: OnCategoryBudgetSet, CheckCategoryBudgets - ListAchievements filtra conquistas por categorias do perfil (sem vazamento entre usuários) - NotifyAction chama CheckCategoryBudgets em transaction_created e import_confirmed - CategoryHandler injeta GameService e chama OnCategoryBudgetSet após Create/Update - 5 novos testes unitários cobrindo todos os cenários de AC
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"financeiro-carvalho/internal/middleware"
|
|
"financeiro-carvalho/internal/model"
|
|
)
|
|
|
|
type GameRepository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewGameRepository(pool *pgxpool.Pool) *GameRepository {
|
|
return &GameRepository{pool: pool}
|
|
}
|
|
|
|
func (r *GameRepository) GetProfile(ctx context.Context) (*model.PlayerProfile, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var p model.PlayerProfile
|
|
err := r.pool.QueryRow(ctx,
|
|
`SELECT id, level, xp, xp_to_next FROM player_profile WHERE profile_id = $1`, pid).
|
|
Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext)
|
|
return &p, err
|
|
}
|
|
|
|
func xpThreshold(level int) int {
|
|
return level * level * 100
|
|
}
|
|
|
|
func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`INSERT INTO xp_events (event_type, xp_earned, description, profile_id) VALUES ($1, $2, $3, $4)`,
|
|
eventType, amount, description, pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var p model.PlayerProfile
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT id, level, xp, xp_to_next FROM player_profile WHERE profile_id = $1`, pid).
|
|
Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p.XP += amount
|
|
for p.XP >= p.XPToNext {
|
|
p.XP -= p.XPToNext
|
|
p.Level++
|
|
p.XPToNext = xpThreshold(p.Level)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`UPDATE player_profile SET level=$1, xp=$2, xp_to_next=$3 WHERE id=$4`,
|
|
p.Level, p.XP, p.XPToNext, p.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &p, tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
now := time.Now()
|
|
dailyPeriod := now.Format("2006-01-02")
|
|
weeklyPeriod := now.Format("2006") + "-W" + now.Format("01")
|
|
monthlyPeriod := now.Format("2006-01")
|
|
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT q.id, q.title, q.quest_type, q.target_count, q.target_pct, q.xp_reward,
|
|
COALESCE(pq.id, 0),
|
|
COALESCE(pq.period, ''),
|
|
COALESCE(pq.current_count, 0),
|
|
COALESCE(pq.current_pct, 0),
|
|
COALESCE(pq.completed, false),
|
|
COALESCE(pq.claimed, false)
|
|
FROM quests q
|
|
LEFT JOIN player_quests pq ON pq.quest_id = q.id AND pq.profile_id = $4 AND pq.period = CASE
|
|
WHEN q.quest_type = 'daily' THEN $1
|
|
WHEN q.quest_type = 'weekly' THEN $2
|
|
WHEN q.quest_type = 'monthly' THEN $3
|
|
END
|
|
WHERE q.active = true
|
|
ORDER BY q.quest_type, q.id
|
|
`, dailyPeriod, weeklyPeriod, monthlyPeriod, pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.PlayerQuest
|
|
for rows.Next() {
|
|
var pq model.PlayerQuest
|
|
var pqID int
|
|
if err := rows.Scan(&pq.QuestID, &pq.Title, &pq.QuestType, &pq.TargetCount, &pq.TargetPct,
|
|
&pq.XPReward, &pqID, &pq.Period, &pq.CurrentCount, &pq.CurrentPct, &pq.Completed, &pq.Claimed); err != nil {
|
|
return nil, err
|
|
}
|
|
pq.ID = pqID
|
|
if pq.Period == "" {
|
|
switch pq.QuestType {
|
|
case "daily":
|
|
pq.Period = dailyPeriod
|
|
case "weekly":
|
|
pq.Period = weeklyPeriod
|
|
case "monthly":
|
|
pq.Period = monthlyPeriod
|
|
}
|
|
}
|
|
out = append(out, pq)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *GameRepository) EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var id int
|
|
err := r.pool.QueryRow(ctx, `
|
|
INSERT INTO player_quests (quest_id, period, profile_id) VALUES ($1, $2, $3)
|
|
ON CONFLICT (quest_id, period, profile_id) DO UPDATE SET quest_id = EXCLUDED.quest_id
|
|
RETURNING id
|
|
`, questID, period, pid).Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, period string, delta int) error {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE player_quests pq
|
|
SET current_count = current_count + $1,
|
|
completed = (current_count + $1 >= q.target_count),
|
|
completed_at = CASE WHEN (current_count + $1 >= q.target_count) AND NOT completed THEN NOW() ELSE completed_at END
|
|
FROM quests q
|
|
WHERE pq.quest_id = q.id
|
|
AND q.quest_type = $2
|
|
AND pq.period = $3
|
|
AND pq.profile_id = $4
|
|
AND q.target_count IS NOT NULL
|
|
AND NOT pq.claimed
|
|
`, delta, questType, period, pid)
|
|
return err
|
|
}
|
|
|
|
func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct float64) error {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE player_quests pq
|
|
SET current_pct = $1,
|
|
completed = ($1 >= q.target_pct),
|
|
completed_at = CASE WHEN ($1 >= q.target_pct) AND NOT completed THEN NOW() ELSE completed_at END
|
|
FROM quests q
|
|
WHERE pq.quest_id = q.id
|
|
AND q.quest_type = 'monthly'
|
|
AND pq.period = $2
|
|
AND pq.profile_id = $3
|
|
AND q.target_pct IS NOT NULL
|
|
AND NOT pq.claimed
|
|
`, pct, period, pid)
|
|
return err
|
|
}
|
|
|
|
func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var xpReward int
|
|
err := r.pool.QueryRow(ctx, `
|
|
UPDATE player_quests pq
|
|
SET claimed = true
|
|
FROM quests q
|
|
WHERE pq.quest_id = q.id AND pq.id = $1 AND pq.profile_id = $2 AND pq.completed AND NOT pq.claimed
|
|
RETURNING q.xp_reward
|
|
`, playerQuestID, pid).Scan(&xpReward)
|
|
return xpReward, err
|
|
}
|
|
|
|
func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, a.category_id,
|
|
pa.id IS NOT NULL,
|
|
COALESCE(pa.earned_at::text, '')
|
|
FROM achievements a
|
|
LEFT JOIN player_achievements pa ON pa.achievement_id = a.id AND pa.profile_id = $1
|
|
WHERE a.category_id IS NULL
|
|
OR a.category_id IN (SELECT id FROM categories WHERE profile_id = $1)
|
|
ORDER BY pa.earned_at DESC NULLS LAST, a.id
|
|
`, pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.Achievement
|
|
for rows.Next() {
|
|
var a model.Achievement
|
|
if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.Icon, &a.XPReward, &a.UnlockCondition, &a.CategoryID, &a.Earned, &a.EarnedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *GameRepository) EnsureCategoryAchievement(ctx context.Context, categoryID int, categoryName string) error {
|
|
title := "Econômico em " + categoryName
|
|
description := "Mantenha os gastos dentro da meta em " + categoryName
|
|
condition := fmt.Sprintf("budget_cat_%d", categoryID)
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO achievements (title, description, icon, xp_reward, unlock_condition, category_id)
|
|
VALUES ($1, $2, '💰', 100, $3, $4)
|
|
ON CONFLICT (unlock_condition) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description
|
|
`, title, description, condition, categoryID)
|
|
return err
|
|
}
|
|
|
|
func (r *GameRepository) RemoveCategoryAchievementIfNotEarned(ctx context.Context, categoryID int) error {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
_, err := r.pool.Exec(ctx, `
|
|
DELETE FROM achievements
|
|
WHERE category_id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM player_achievements pa
|
|
WHERE pa.achievement_id = achievements.id AND pa.profile_id = $2
|
|
)
|
|
`, categoryID, pid)
|
|
return err
|
|
}
|
|
|
|
func (r *GameRepository) ListCategoriesWithBudget(ctx context.Context) ([]model.CategoryBudgetInfo, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT id, monthly_budget FROM categories
|
|
WHERE profile_id = $1 AND monthly_budget IS NOT NULL
|
|
`, pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.CategoryBudgetInfo
|
|
for rows.Next() {
|
|
var c model.CategoryBudgetInfo
|
|
if err := rows.Scan(&c.CategoryID, &c.MonthlyBudget); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *GameRepository) GetMonthlySpendByCategory(ctx context.Context, month string, categoryID int) (float64, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var total float64
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT COALESCE(SUM(amount), 0)
|
|
FROM transactions
|
|
WHERE to_char(date, 'YYYY-MM') = $1
|
|
AND category_id = $2
|
|
AND type = 'expense'
|
|
AND profile_id = $3
|
|
`, month, categoryID, pid).Scan(&total)
|
|
return total, err
|
|
}
|
|
|
|
func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var count int
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM player_achievements pa
|
|
JOIN achievements a ON a.id = pa.achievement_id
|
|
WHERE a.unlock_condition = $1 AND pa.profile_id = $2
|
|
`, condition, pid).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
func (r *GameRepository) UnlockAchievement(ctx context.Context, condition string) (int, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var xpReward int
|
|
err := r.pool.QueryRow(ctx, `
|
|
INSERT INTO player_achievements (achievement_id, profile_id)
|
|
SELECT id, $2 FROM achievements WHERE unlock_condition = $1
|
|
ON CONFLICT (achievement_id, profile_id) DO NOTHING
|
|
RETURNING (SELECT xp_reward FROM achievements WHERE unlock_condition = $1)
|
|
`, condition, pid).Scan(&xpReward)
|
|
return xpReward, err
|
|
}
|
|
|
|
func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
var level int
|
|
_ = r.pool.QueryRow(ctx,
|
|
`SELECT level FROM player_profile WHERE profile_id = $1`, pid).Scan(&level)
|
|
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT c.id, c.name, c.type, c.unlock_level, c.css_data::text,
|
|
(c.unlock_level <= $1) AS unlocked,
|
|
COALESCE(pc.equipped, false)
|
|
FROM cosmetics c
|
|
LEFT JOIN player_cosmetics pc ON pc.cosmetic_id = c.id AND pc.profile_id = $2
|
|
ORDER BY c.unlock_level, c.id
|
|
`, level, pid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []model.Cosmetic
|
|
for rows.Next() {
|
|
var c model.Cosmetic
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.UnlockLevel, &c.CSSData, &c.Unlocked, &c.Equipped); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *GameRepository) EquipCosmetic(ctx context.Context, cosmeticID int) error {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE player_cosmetics pc SET equipped = false
|
|
FROM cosmetics c WHERE pc.cosmetic_id = c.id
|
|
AND c.type = (SELECT type FROM cosmetics WHERE id = $1)
|
|
AND pc.profile_id = $2
|
|
`, cosmeticID, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO player_cosmetics (cosmetic_id, equipped, profile_id) VALUES ($1, true, $2)
|
|
ON CONFLICT (cosmetic_id, profile_id) DO UPDATE SET equipped = true
|
|
`, cosmeticID, pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *GameRepository) UnlockCosmeticsForLevel(ctx context.Context, level int) error {
|
|
pid := middleware.ProfileIDFromCtx(ctx)
|
|
_, err := r.pool.Exec(ctx, `
|
|
INSERT INTO player_cosmetics (cosmetic_id, profile_id)
|
|
SELECT id, $2 FROM cosmetics WHERE unlock_level <= $1
|
|
ON CONFLICT (cosmetic_id, profile_id) DO NOTHING
|
|
`, level, pid)
|
|
return err
|
|
}
|