feat(game): #49 motor de conquistas dinâmicas por categoria
- 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
This commit is contained in:
@@ -2,6 +2,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -187,11 +188,13 @@ func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int
|
||||
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,
|
||||
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 {
|
||||
@@ -202,7 +205,7 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem
|
||||
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.Earned, &a.EarnedAt); err != nil {
|
||||
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)
|
||||
@@ -210,6 +213,67 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user