diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 5a22400..6bc4af2 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -55,14 +55,14 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) - categoryRepo := repository.NewCategoryRepository(pool) - categorySvc := service.NewCategoryService(categoryRepo) - categoryHandler := handler.NewCategoryHandler(categorySvc) - gameRepo := repository.NewGameRepository(pool) gameSvc := service.NewGameService(gameRepo) gameHandler := handler.NewGameHandler(gameSvc) + categoryRepo := repository.NewCategoryRepository(pool) + categorySvc := service.NewCategoryService(categoryRepo) + categoryHandler := handler.NewCategoryHandler(categorySvc, gameSvc) + transactionRepo := repository.NewTransactionRepository(pool) importSvc := service.NewImportService(transactionRepo) pendingBillRepo := repository.NewPendingBillRepository(pool) diff --git a/apps/api/internal/handler/category.go b/apps/api/internal/handler/category.go index 1de1888..9ea9a18 100644 --- a/apps/api/internal/handler/category.go +++ b/apps/api/internal/handler/category.go @@ -14,11 +14,12 @@ import ( ) type CategoryHandler struct { - svc *service.CategoryService + svc *service.CategoryService + gameSvc *service.GameService } -func NewCategoryHandler(svc *service.CategoryService) *CategoryHandler { - return &CategoryHandler{svc: svc} +func NewCategoryHandler(svc *service.CategoryService, gameSvc *service.GameService) *CategoryHandler { + return &CategoryHandler{svc: svc, gameSvc: gameSvc} } func (h *CategoryHandler) List(w http.ResponseWriter, r *http.Request) { @@ -44,6 +45,7 @@ func (h *CategoryHandler) Create(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, err.Error()) return } + h.gameSvc.OnCategoryBudgetSet(r.Context(), cat.ID, cat.Name, in.MonthlyBudget) respondJSON(w, http.StatusCreated, cat) } @@ -67,6 +69,7 @@ func (h *CategoryHandler) Update(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, err.Error()) return } + h.gameSvc.OnCategoryBudgetSet(r.Context(), cat.ID, cat.Name, in.MonthlyBudget) respondJSON(w, http.StatusOK, cat) } diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index d78c611..9eadf9c 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -59,6 +59,9 @@ var m016 string //go:embed sql/017_user_settings.sql var m017 string +//go:embed sql/018_dynamic_achievements.sql +var m018 string + func Run(ctx context.Context, pool *pgxpool.Pool) error { // Bootstrap: ensure schema_migrations table exists before checking versions. if _, err := pool.Exec(ctx, ` @@ -70,7 +73,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error { return fmt.Errorf("bootstrap schema_migrations: %w", err) } - migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017} + migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017, m018} for i, sql := range migrations { version := i + 1 var applied bool diff --git a/apps/api/internal/migration/sql/018_dynamic_achievements.sql b/apps/api/internal/migration/sql/018_dynamic_achievements.sql new file mode 100644 index 0000000..8e616ca --- /dev/null +++ b/apps/api/internal/migration/sql/018_dynamic_achievements.sql @@ -0,0 +1,17 @@ +ALTER TABLE categories ADD COLUMN IF NOT EXISTS monthly_budget NUMERIC(12,2); + +ALTER TABLE achievements ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE; + +-- Remove conquistas hardcoded com categorias específicas +DELETE FROM player_achievements WHERE achievement_id IN ( + SELECT id FROM achievements WHERE unlock_condition IN ('veleiro_under_500', 'health_3months') +); +DELETE FROM achievements WHERE unlock_condition IN ('veleiro_under_500', 'health_3months'); + +-- Reset game state do profile_id=1 +DELETE FROM player_achievements WHERE profile_id = 1; +DELETE FROM xp_events WHERE profile_id = 1; +DELETE FROM player_quests WHERE profile_id = 1; +UPDATE player_profile SET level = 1, xp = 0, xp_to_next = 100 WHERE profile_id = 1; + +INSERT INTO schema_migrations (version) VALUES (18) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/model/category.go b/apps/api/internal/model/category.go index d16309f..c4b182d 100644 --- a/apps/api/internal/model/category.go +++ b/apps/api/internal/model/category.go @@ -3,17 +3,19 @@ package model import "time" type Category struct { - ID int `json:"id"` - Name string `json:"name"` - Color string `json:"color"` - IsDefault bool `json:"is_default"` - IsTax bool `json:"is_tax"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + IsDefault bool `json:"is_default"` + IsTax bool `json:"is_tax"` + MonthlyBudget *float64 `json:"monthly_budget"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type CategoryInput struct { - Name string `json:"name"` - Color string `json:"color"` - IsTax bool `json:"is_tax"` + Name string `json:"name"` + Color string `json:"color"` + IsTax bool `json:"is_tax"` + MonthlyBudget *float64 `json:"monthly_budget"` } diff --git a/apps/api/internal/model/game.go b/apps/api/internal/model/game.go index fa74bb8..73008b7 100644 --- a/apps/api/internal/model/game.go +++ b/apps/api/internal/model/game.go @@ -41,14 +41,20 @@ type PlayerQuest struct { } type Achievement struct { - ID int `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` - XPReward int `json:"xp_reward"` - UnlockCondition string `json:"unlock_condition"` - Earned bool `json:"earned"` - EarnedAt string `json:"earned_at,omitempty"` + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + XPReward int `json:"xp_reward"` + UnlockCondition string `json:"unlock_condition"` + CategoryID *int `json:"category_id,omitempty"` + Earned bool `json:"earned"` + EarnedAt string `json:"earned_at,omitempty"` +} + +type CategoryBudgetInfo struct { + CategoryID int + MonthlyBudget float64 } type Cosmetic struct { diff --git a/apps/api/internal/repository/category.go b/apps/api/internal/repository/category.go index 152a92a..c97f2cb 100644 --- a/apps/api/internal/repository/category.go +++ b/apps/api/internal/repository/category.go @@ -16,8 +16,8 @@ var ErrNotFound = errors.New("not found") type CategoryRepository interface { List(ctx context.Context) ([]model.Category, error) GetByID(ctx context.Context, id int) (*model.Category, error) - Create(ctx context.Context, name, color string, isTax bool) (*model.Category, error) - Update(ctx context.Context, id int, name, color string, isTax bool) (*model.Category, error) + Create(ctx context.Context, name, color string, isTax bool, budget *float64) (*model.Category, error) + Update(ctx context.Context, id int, name, color string, isTax bool, budget *float64) (*model.Category, error) Delete(ctx context.Context, id int) error HasTransactions(ctx context.Context, id int) (bool, error) } @@ -31,7 +31,7 @@ func NewCategoryRepository(db *pgxpool.Pool) CategoryRepository { func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { pid := middleware.ProfileIDFromCtx(ctx) rows, err := r.db.Query(ctx, ` - SELECT id, name, color, is_default, is_tax, created_at, updated_at + SELECT id, name, color, is_default, is_tax, monthly_budget, created_at, updated_at FROM categories WHERE profile_id = $1 ORDER BY is_default DESC, name ASC`, pid) @@ -43,7 +43,7 @@ func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) { var out []model.Category for rows.Next() { var c model.Category - if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt); err != nil { + if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.MonthlyBudget, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } out = append(out, c) @@ -55,37 +55,37 @@ func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, er pid := middleware.ProfileIDFromCtx(ctx) var c model.Category err := r.db.QueryRow(ctx, ` - SELECT id, name, color, is_default, is_tax, created_at, updated_at + SELECT id, name, color, is_default, is_tax, monthly_budget, created_at, updated_at FROM categories WHERE id = $1 AND profile_id = $2`, id, pid). - Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.MonthlyBudget, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return &c, err } -func (r *categoryRepo) Create(ctx context.Context, name, color string, isTax bool) (*model.Category, error) { +func (r *categoryRepo) Create(ctx context.Context, name, color string, isTax bool, budget *float64) (*model.Category, error) { pid := middleware.ProfileIDFromCtx(ctx) var c model.Category err := r.db.QueryRow(ctx, ` - INSERT INTO categories (name, color, is_tax, profile_id) - VALUES ($1, $2, $3, $4) - RETURNING id, name, color, is_default, is_tax, created_at, updated_at`, - name, color, isTax, pid). - Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) + INSERT INTO categories (name, color, is_tax, monthly_budget, profile_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, name, color, is_default, is_tax, monthly_budget, created_at, updated_at`, + name, color, isTax, budget, pid). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.MonthlyBudget, &c.CreatedAt, &c.UpdatedAt) return &c, err } -func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, isTax bool) (*model.Category, error) { +func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, isTax bool, budget *float64) (*model.Category, error) { pid := middleware.ProfileIDFromCtx(ctx) var c model.Category err := r.db.QueryRow(ctx, ` UPDATE categories - SET name = $1, color = $2, is_tax = $3, updated_at = NOW() - WHERE id = $4 AND profile_id = $5 - RETURNING id, name, color, is_default, is_tax, created_at, updated_at`, - name, color, isTax, id, pid). - Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt) + SET name = $1, color = $2, is_tax = $3, monthly_budget = $4, updated_at = NOW() + WHERE id = $5 AND profile_id = $6 + RETURNING id, name, color, is_default, is_tax, monthly_budget, created_at, updated_at`, + name, color, isTax, budget, id, pid). + Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.MonthlyBudget, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } diff --git a/apps/api/internal/service/category.go b/apps/api/internal/service/category.go index 49cd817..292cf4b 100644 --- a/apps/api/internal/service/category.go +++ b/apps/api/internal/service/category.go @@ -32,14 +32,14 @@ func (s *CategoryService) Create(ctx context.Context, in model.CategoryInput) (* if err := validateInput(in); err != nil { return nil, err } - return s.repo.Create(ctx, strings.TrimSpace(in.Name), in.Color, in.IsTax) + return s.repo.Create(ctx, strings.TrimSpace(in.Name), in.Color, in.IsTax, in.MonthlyBudget) } func (s *CategoryService) Update(ctx context.Context, id int, in model.CategoryInput) (*model.Category, error) { if err := validateInput(in); err != nil { return nil, err } - return s.repo.Update(ctx, id, strings.TrimSpace(in.Name), in.Color, in.IsTax) + return s.repo.Update(ctx, id, strings.TrimSpace(in.Name), in.Color, in.IsTax, in.MonthlyBudget) } func (s *CategoryService) Delete(ctx context.Context, id int) error { diff --git a/apps/api/internal/service/category_test.go b/apps/api/internal/service/category_test.go index 865dccc..1e31a1a 100644 --- a/apps/api/internal/service/category_test.go +++ b/apps/api/internal/service/category_test.go @@ -33,13 +33,13 @@ func (m *mockCategoryRepo) GetByID(_ context.Context, id int) (*model.Category, return nil, repository.ErrNotFound } -func (m *mockCategoryRepo) Create(_ context.Context, name, color string, _ bool) (*model.Category, error) { +func (m *mockCategoryRepo) Create(_ context.Context, name, color string, _ bool, _ *float64) (*model.Category, error) { c := model.Category{ID: len(m.categories) + 1, Name: name, Color: color} m.categories = append(m.categories, c) return &c, nil } -func (m *mockCategoryRepo) Update(_ context.Context, id int, name, color string, _ bool) (*model.Category, error) { +func (m *mockCategoryRepo) Update(_ context.Context, id int, name, color string, _ bool, _ *float64) (*model.Category, error) { for i, c := range m.categories { if c.ID == id { m.categories[i].Name = name