diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 70bc65a..060363e 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -52,13 +52,17 @@ func main() { categorySvc := service.NewCategoryService(categoryRepo) categoryHandler := handler.NewCategoryHandler(categorySvc) + gameRepo := repository.NewGameRepository(pool) + gameSvc := service.NewGameService(gameRepo) + gameHandler := handler.NewGameHandler(gameSvc) + transactionRepo := repository.NewTransactionRepository(pool) importSvc := service.NewImportService(transactionRepo) - importHandler := handler.NewImportHandler(importSvc) + importHandler := handler.NewImportHandler(importSvc, gameSvc) manualTxRepo := repository.NewManualTransactionRepository(pool) txSvc := service.NewTransactionService(manualTxRepo) - txHandler := handler.NewTransactionHandler(txSvc) + txHandler := handler.NewTransactionHandler(txSvc, gameSvc) recurringRepo := repository.NewRecurringRepository(pool) recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo) @@ -102,6 +106,13 @@ func main() { r.Delete("/accounts/{id}", accountHandler.Delete) r.Get("/dashboard", dashboardHandler.Get) + + r.Get("/game/summary", gameHandler.Summary) + r.Post("/game/xp", gameHandler.AwardXP) + r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest) + r.Get("/game/achievements", gameHandler.Achievements) + r.Get("/game/cosmetics", gameHandler.Cosmetics) + r.Post("/game/cosmetics/{id}/equip", gameHandler.EquipCosmetic) }) // Serve Vue SPA — non-API routes fall through to index.html diff --git a/apps/api/go.mod b/apps/api/go.mod index fa577ee..8791908 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -7,3 +7,12 @@ require ( github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 ) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/apps/api/internal/handler/game.go b/apps/api/internal/handler/game.go new file mode 100644 index 0000000..6600e83 --- /dev/null +++ b/apps/api/internal/handler/game.go @@ -0,0 +1,90 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "financeiro-carvalho/internal/service" +) + +type GameHandler struct { + svc *service.GameService +} + +func NewGameHandler(svc *service.GameService) *GameHandler { + return &GameHandler{svc: svc} +} + +func (h *GameHandler) Summary(w http.ResponseWriter, r *http.Request) { + s, err := h.svc.GetSummary(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to get game summary") + return + } + respondJSON(w, http.StatusOK, s) +} + +func (h *GameHandler) AwardXP(w http.ResponseWriter, r *http.Request) { + var body struct { + EventType string `json:"event_type"` + Amount int `json:"amount"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + respondError(w, http.StatusBadRequest, "invalid JSON") + return + } + p, err := h.svc.AwardXP(r.Context(), body.EventType, body.Amount, body.Description) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to award xp") + return + } + respondJSON(w, http.StatusOK, p) +} + +func (h *GameHandler) ClaimQuest(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.svc.ClaimQuest(r.Context(), id); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *GameHandler) Achievements(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.ListAllAchievements(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list achievements") + return + } + respondJSON(w, http.StatusOK, list) +} + +func (h *GameHandler) Cosmetics(w http.ResponseWriter, r *http.Request) { + list, err := h.svc.ListCosmetics(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list cosmetics") + return + } + respondJSON(w, http.StatusOK, list) +} + +func (h *GameHandler) EquipCosmetic(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid id") + return + } + if err := h.svc.EquipCosmetic(r.Context(), id); err != nil { + respondError(w, http.StatusInternalServerError, "failed to equip cosmetic") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/apps/api/internal/handler/import.go b/apps/api/internal/handler/import.go index 230c48a..acffc5c 100644 --- a/apps/api/internal/handler/import.go +++ b/apps/api/internal/handler/import.go @@ -11,11 +11,12 @@ import ( const maxUploadSize = 10 << 20 // 10 MB type ImportHandler struct { - svc *service.ImportService + svc *service.ImportService + gameSvc *service.GameService } -func NewImportHandler(svc *service.ImportService) *ImportHandler { - return &ImportHandler{svc: svc} +func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler { + return &ImportHandler{svc: svc, gameSvc: gameSvc} } // Preview parses the uploaded file and returns rows with duplicate flags. @@ -78,5 +79,6 @@ func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to save transactions") return } + h.gameSvc.NotifyAction(r.Context(), "import_confirmed", map[string]any{"count": result.Imported}) respondJSON(w, http.StatusOK, result) } diff --git a/apps/api/internal/handler/transaction.go b/apps/api/internal/handler/transaction.go index f344447..e03f1e4 100644 --- a/apps/api/internal/handler/transaction.go +++ b/apps/api/internal/handler/transaction.go @@ -14,11 +14,12 @@ import ( ) type TransactionHandler struct { - svc *service.TransactionService + svc *service.TransactionService + gameSvc *service.GameService } -func NewTransactionHandler(svc *service.TransactionService) *TransactionHandler { - return &TransactionHandler{svc: svc} +func NewTransactionHandler(svc *service.TransactionService, gameSvc *service.GameService) *TransactionHandler { + return &TransactionHandler{svc: svc, gameSvc: gameSvc} } func (h *TransactionHandler) List(w http.ResponseWriter, r *http.Request) { @@ -42,6 +43,7 @@ func (h *TransactionHandler) Create(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusBadRequest, err.Error()) return } + h.gameSvc.NotifyAction(r.Context(), "transaction_created", map[string]any{"category_id": out.CategoryID}) respondJSON(w, http.StatusCreated, out) } diff --git a/apps/api/internal/migration/migration.go b/apps/api/internal/migration/migration.go index a5b588b..cc0e635 100644 --- a/apps/api/internal/migration/migration.go +++ b/apps/api/internal/migration/migration.go @@ -20,8 +20,20 @@ var m003 string //go:embed sql/004_accounts.sql var m004 string +//go:embed sql/005_game_profile.sql +var m005 string + +//go:embed sql/006_quests.sql +var m006 string + +//go:embed sql/007_achievements.sql +var m007 string + +//go:embed sql/008_cosmetics.sql +var m008 string + func Run(ctx context.Context, pool *pgxpool.Pool) error { - migrations := []string{m001, m002, m003, m004} + migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008} for i, sql := range migrations { if _, err := pool.Exec(ctx, sql); err != nil { return fmt.Errorf("migration %03d: %w", i+1, err) diff --git a/apps/api/internal/migration/sql/005_game_profile.sql b/apps/api/internal/migration/sql/005_game_profile.sql new file mode 100644 index 0000000..c52c32f --- /dev/null +++ b/apps/api/internal/migration/sql/005_game_profile.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS player_profile ( + id SERIAL PRIMARY KEY, + level INTEGER NOT NULL DEFAULT 1, + xp INTEGER NOT NULL DEFAULT 0, + xp_to_next INTEGER NOT NULL DEFAULT 100 +); +INSERT INTO player_profile (level, xp, xp_to_next) VALUES (1, 0, 100) +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS xp_events ( + id SERIAL PRIMARY KEY, + event_type VARCHAR(50) NOT NULL, + xp_earned INTEGER NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +INSERT INTO schema_migrations (version) VALUES (5) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/migration/sql/006_quests.sql b/apps/api/internal/migration/sql/006_quests.sql new file mode 100644 index 0000000..a1c1467 --- /dev/null +++ b/apps/api/internal/migration/sql/006_quests.sql @@ -0,0 +1,32 @@ +CREATE TABLE IF NOT EXISTS quests ( + id SERIAL PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description TEXT, + quest_type VARCHAR(20) NOT NULL CHECK (quest_type IN ('daily','weekly','monthly')), + target_count INTEGER, + target_pct NUMERIC(5,2), + xp_reward INTEGER NOT NULL DEFAULT 50, + active BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS player_quests ( + id SERIAL PRIMARY KEY, + quest_id INTEGER NOT NULL REFERENCES quests(id) ON DELETE CASCADE, + period VARCHAR(10) NOT NULL, + current_count INTEGER NOT NULL DEFAULT 0, + current_pct NUMERIC(5,2) NOT NULL DEFAULT 0, + completed BOOLEAN NOT NULL DEFAULT FALSE, + completed_at TIMESTAMP WITH TIME ZONE, + claimed BOOLEAN NOT NULL DEFAULT FALSE, + UNIQUE (quest_id, period) +); + +INSERT INTO quests (title, description, quest_type, target_count, target_pct, xp_reward) VALUES + ('Economista do Mês', 'Poupe ≥40% da receita este mês', 'monthly', NULL, 40, 200), + ('Capitão Organizado', 'Importe 1 extrato esta semana', 'weekly', 1, NULL, 50), + ('Categorizador Ativo', 'Categorize 5 transações hoje', 'daily', 5, NULL, 30), + ('Controlador Semanal', 'Registre ao menos 3 transações esta semana', 'weekly', 3, NULL, 40), + ('Mês Sem Surpresas', 'Cubra todas as recorrências do mês', 'monthly', NULL, NULL, 150) +ON CONFLICT DO NOTHING; + +INSERT INTO schema_migrations (version) VALUES (6) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/migration/sql/007_achievements.sql b/apps/api/internal/migration/sql/007_achievements.sql new file mode 100644 index 0000000..ee7cccc --- /dev/null +++ b/apps/api/internal/migration/sql/007_achievements.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS achievements ( + id SERIAL PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(10) NOT NULL DEFAULT '🏆', + xp_reward INTEGER NOT NULL DEFAULT 100, + unlock_condition VARCHAR(100) NOT NULL UNIQUE +); + +CREATE TABLE IF NOT EXISTS player_achievements ( + id SERIAL PRIMARY KEY, + achievement_id INTEGER NOT NULL REFERENCES achievements(id) ON DELETE CASCADE UNIQUE, + earned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +INSERT INTO achievements (title, description, icon, xp_reward, unlock_condition) VALUES + ('Primeiro Passo', 'Registre sua primeira transação manual', '👣', 50, 'first_transaction'), + ('Explorador de Extratos', 'Importe seu primeiro extrato', '📄', 100, 'first_import'), + ('Meta Atingida!', 'Poupe 40% pela primeira vez', '🎯', 200, 'first_40pct'), + ('Tri-Campeão', 'Poupe 40% por 3 meses seguidos', '🥇', 500, 'three_months_40pct'), + ('Veleirista Econômico', 'Gaste menos de R$500 no Veleiro em um mês', '⛵', 150, 'veleiro_under_500'), + ('Nível 5', 'Alcance o nível 5', '⭐', 100, 'level_5'), + ('Nível 10', 'Alcance o nível 10', '🌟', 300, 'level_10'), + ('Mestre das Categorias', 'Categorize 100 transações', '🗂️', 200, 'categorize_100'), + ('Semana Perfeita', 'Importe extrato por 4 semanas seguidas', '📅', 250, 'four_weeks_import'), + ('Guardião da Saúde', 'Registre gasto com Saúde/Insulina em 3 meses', '💉', 300, 'health_3months'), + ('Mês Limpo', 'Todas as recorrências cobertas em um mês', '✨', 200, 'full_recurring_month') +ON CONFLICT DO NOTHING; + +INSERT INTO schema_migrations (version) VALUES (7) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/migration/sql/008_cosmetics.sql b/apps/api/internal/migration/sql/008_cosmetics.sql new file mode 100644 index 0000000..e48efa8 --- /dev/null +++ b/apps/api/internal/migration/sql/008_cosmetics.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS cosmetics ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + type VARCHAR(20) NOT NULL CHECK (type IN ('hat','outfit','accessory')), + unlock_level INTEGER NOT NULL, + css_data JSONB NOT NULL DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS player_cosmetics ( + id SERIAL PRIMARY KEY, + cosmetic_id INTEGER NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE UNIQUE, + equipped BOOLEAN NOT NULL DEFAULT FALSE, + unlocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +INSERT INTO cosmetics (name, type, unlock_level, css_data) VALUES + ('Chapéu de Aventureiro', 'hat', 2, '{"color":"#8B4513","accent":"#d4a85a"}'), + ('Roupa de Marinheiro', 'outfit', 3, '{"primary":"#1e3a5f","accent":"#ffffff"}'), + ('Óculos de Sol', 'accessory', 4, '{"color":"#1a1a1a"}'), + ('Chapéu de Capitão', 'hat', 5, '{"color":"#1e293b","badge":"#ffd700"}'), + ('Roupa Dourada', 'outfit', 7, '{"primary":"#d4af37","accent":"#ffd700"}'), + ('Coroa de Campeão', 'hat', 10, '{"color":"#ffd700","gems":"#e11d48"}') +ON CONFLICT DO NOTHING; + +INSERT INTO schema_migrations (version) VALUES (8) ON CONFLICT DO NOTHING; diff --git a/apps/api/internal/model/game.go b/apps/api/internal/model/game.go new file mode 100644 index 0000000..fa74bb8 --- /dev/null +++ b/apps/api/internal/model/game.go @@ -0,0 +1,69 @@ +package model + +type PlayerProfile struct { + ID int `json:"id"` + Level int `json:"level"` + XP int `json:"xp"` + XPToNext int `json:"xp_to_next"` +} + +type XPEvent struct { + ID int `json:"id"` + EventType string `json:"event_type"` + XPEarned int `json:"xp_earned"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +type Quest struct { + ID int `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + QuestType string `json:"quest_type"` + TargetCount *int `json:"target_count"` + TargetPct *float64 `json:"target_pct"` + XPReward int `json:"xp_reward"` +} + +type PlayerQuest struct { + ID int `json:"id"` + QuestID int `json:"quest_id"` + Title string `json:"title"` + QuestType string `json:"quest_type"` + TargetCount *int `json:"target_count"` + TargetPct *float64 `json:"target_pct"` + XPReward int `json:"xp_reward"` + Period string `json:"period"` + CurrentCount int `json:"current_count"` + CurrentPct float64 `json:"current_pct"` + Completed bool `json:"completed"` + Claimed bool `json:"claimed"` +} + +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"` +} + +type Cosmetic struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + UnlockLevel int `json:"unlock_level"` + CSSData string `json:"css_data"` + Unlocked bool `json:"unlocked"` + Equipped bool `json:"equipped"` +} + +type GameSummary struct { + Profile PlayerProfile `json:"profile"` + ActiveQuests []PlayerQuest `json:"active_quests"` + RecentAchievements []Achievement `json:"recent_achievements"` + EquippedCosmetics []Cosmetic `json:"equipped_cosmetics"` +} diff --git a/apps/api/internal/repository/game.go b/apps/api/internal/repository/game.go new file mode 100644 index 0000000..a24eaa8 --- /dev/null +++ b/apps/api/internal/repository/game.go @@ -0,0 +1,282 @@ +package repository + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "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) { + var p model.PlayerProfile + err := r.pool.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`). + Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext) + return &p, err +} + +// xpThreshold returns the XP required to reach the given level. +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) { + 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) VALUES ($1, $2, $3)`, + eventType, amount, description) + if err != nil { + return nil, err + } + + var p model.PlayerProfile + err = tx.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`). + 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) { + 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.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) + 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) { + var id int + err := r.pool.QueryRow(ctx, ` + INSERT INTO player_quests (quest_id, period) VALUES ($1, $2) + ON CONFLICT (quest_id, period) DO UPDATE SET quest_id = EXCLUDED.quest_id + RETURNING id + `, questID, period).Scan(&id) + return id, err +} + +func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, period string, delta int) error { + _, 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 q.target_count IS NOT NULL + AND NOT pq.claimed + `, delta, questType, period) + return err +} + +func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct float64) error { + _, 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 q.target_pct IS NOT NULL + AND NOT pq.claimed + `, pct, period) + return err +} + +func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int, error) { + 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.completed AND NOT pq.claimed + RETURNING q.xp_reward + `, playerQuestID).Scan(&xpReward) + return xpReward, err +} + +func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) { + rows, err := r.pool.Query(ctx, ` + SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, + pa.id IS NOT NULL, + COALESCE(pa.earned_at::text, '') + FROM achievements a + LEFT JOIN player_achievements pa ON pa.achievement_id = a.id + ORDER BY pa.earned_at DESC NULLS LAST, a.id + `) + 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.Earned, &a.EarnedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) { + 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 + `, condition).Scan(&count) + return count > 0, err +} + +func (r *GameRepository) UnlockAchievement(ctx context.Context, condition string) (int, error) { + var xpReward int + err := r.pool.QueryRow(ctx, ` + INSERT INTO player_achievements (achievement_id) + SELECT id FROM achievements WHERE unlock_condition = $1 + ON CONFLICT (achievement_id) DO NOTHING + RETURNING (SELECT xp_reward FROM achievements WHERE unlock_condition = $1) + `, condition).Scan(&xpReward) + return xpReward, err +} + +func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) { + var level int + _ = r.pool.QueryRow(ctx, `SELECT level FROM player_profile LIMIT 1`).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 + ORDER BY c.unlock_level, c.id + `, level) + 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 { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // unequip same type + _, 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) + `, cosmeticID) + if err != nil { + return err + } + + _, err = tx.Exec(ctx, ` + INSERT INTO player_cosmetics (cosmetic_id, equipped) VALUES ($1, true) + ON CONFLICT (cosmetic_id) DO UPDATE SET equipped = true + `, cosmeticID) + if err != nil { + return err + } + return tx.Commit(ctx) +} + +func (r *GameRepository) UnlockCosmeticsForLevel(ctx context.Context, level int) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO player_cosmetics (cosmetic_id) + SELECT id FROM cosmetics WHERE unlock_level <= $1 + ON CONFLICT (cosmetic_id) DO NOTHING + `, level) + return err +} diff --git a/apps/api/internal/service/game.go b/apps/api/internal/service/game.go new file mode 100644 index 0000000..58f7c7f --- /dev/null +++ b/apps/api/internal/service/game.go @@ -0,0 +1,174 @@ +package service + +import ( + "context" + "time" + + "financeiro-carvalho/internal/model" +) + +type GameRepo interface { + GetProfile(ctx context.Context) (*model.PlayerProfile, error) + AddXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) + ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error) + EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error) + IncrementQuestCount(ctx context.Context, questType, period string, delta int) error + UpdateQuestPct(ctx context.Context, period string, pct float64) error + ClaimQuest(ctx context.Context, playerQuestID int) (int, error) + ListAchievements(ctx context.Context) ([]model.Achievement, error) + IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) + UnlockAchievement(ctx context.Context, condition string) (int, error) + ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) + EquipCosmetic(ctx context.Context, cosmeticID int) error + UnlockCosmeticsForLevel(ctx context.Context, level int) error +} + +type GameService struct { + repo GameRepo +} + +func NewGameService(repo GameRepo) *GameService { + return &GameService{repo: repo} +} + +func (s *GameService) GetProfile(ctx context.Context) (*model.PlayerProfile, error) { + return s.repo.GetProfile(ctx) +} + +func (s *GameService) AwardXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) { + prevProfile, _ := s.repo.GetProfile(ctx) + profile, err := s.repo.AddXP(ctx, eventType, amount, description) + if err != nil { + return nil, err + } + // unlock cosmetics if levelled up + if prevProfile != nil && profile.Level > prevProfile.Level { + _ = s.repo.UnlockCosmeticsForLevel(ctx, profile.Level) + if profile.Level >= 5 { + s.tryUnlockAchievement(ctx, "level_5") + } + if profile.Level >= 10 { + s.tryUnlockAchievement(ctx, "level_10") + } + } + return profile, nil +} + +func (s *GameService) tryUnlockAchievement(ctx context.Context, condition string) { + unlocked, _ := s.repo.IsAchievementUnlocked(ctx, condition) + if unlocked { + return + } + xp, err := s.repo.UnlockAchievement(ctx, condition) + if err == nil && xp > 0 { + _, _ = s.repo.AddXP(ctx, "achievement_"+condition, xp, "Achievement desbloqueado") + } +} + +func (s *GameService) CheckAndUnlockAchievement(ctx context.Context, condition string) { + s.tryUnlockAchievement(ctx, condition) +} + +func (s *GameService) GetSummary(ctx context.Context) (*model.GameSummary, error) { + profile, err := s.repo.GetProfile(ctx) + if err != nil { + return nil, err + } + + quests, err := s.repo.ListActiveQuests(ctx) + if err != nil { + return nil, err + } + if quests == nil { + quests = []model.PlayerQuest{} + } + + achievements, err := s.repo.ListAchievements(ctx) + if err != nil { + return nil, err + } + recent := []model.Achievement{} + for _, a := range achievements { + if a.Earned { + recent = append(recent, a) + if len(recent) >= 3 { + break + } + } + } + + cosmetics, err := s.repo.ListCosmetics(ctx) + if err != nil { + return nil, err + } + equipped := []model.Cosmetic{} + for _, c := range cosmetics { + if c.Equipped { + equipped = append(equipped, c) + } + } + + return &model.GameSummary{ + Profile: *profile, + ActiveQuests: quests, + RecentAchievements: recent, + EquippedCosmetics: equipped, + }, nil +} + +func (s *GameService) ClaimQuest(ctx context.Context, playerQuestID int) error { + xp, err := s.repo.ClaimQuest(ctx, playerQuestID) + if err != nil || xp == 0 { + return err + } + _, err = s.AwardXP(ctx, "quest_claimed", xp, "Quest concluída") + return err +} + +func (s *GameService) ListAllAchievements(ctx context.Context) ([]model.Achievement, error) { + return s.repo.ListAchievements(ctx) +} + +func (s *GameService) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) { + return s.repo.ListCosmetics(ctx) +} + +func (s *GameService) EquipCosmetic(ctx context.Context, cosmeticID int) error { + return s.repo.EquipCosmetic(ctx, cosmeticID) +} + +// NotifyAction is called by other handlers after key financial actions to award XP and update quests. +func (s *GameService) NotifyAction(ctx context.Context, action string, extra map[string]any) { + now := time.Now() + monthlyPeriod := now.Format("2006-01") + weeklyPeriod := now.Format("2006") + "-W" + now.Format("01") + dailyPeriod := now.Format("2006-01-02") + + switch action { + case "transaction_created": + _, _ = s.AwardXP(ctx, "transaction_created", 5, "Transação registrada") + if catID, ok := extra["category_id"]; ok && catID != nil { + _, _ = s.AwardXP(ctx, "transaction_categorized", 10, "Transação categorizada") + _ = s.repo.IncrementQuestCount(ctx, "daily", dailyPeriod, 1) + _ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1) + } + s.tryUnlockAchievement(ctx, "first_transaction") + + case "import_confirmed": + count, _ := extra["count"].(int) + if count < 1 { + count = 1 + } + _, _ = s.AwardXP(ctx, "import_confirmed", 50, "Extrato importado") + _ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1) + s.tryUnlockAchievement(ctx, "first_import") + + case "savings_checked": + pct, _ := extra["pct"].(float64) + _ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct) + if pct >= 40 { + _, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de 40% atingida!") + s.tryUnlockAchievement(ctx, "first_40pct") + } + } +} diff --git a/apps/api/internal/service/game_test.go b/apps/api/internal/service/game_test.go new file mode 100644 index 0000000..1449c89 --- /dev/null +++ b/apps/api/internal/service/game_test.go @@ -0,0 +1,98 @@ +package service_test + +import ( + "context" + "testing" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/service" +) + +type mockGameRepo struct { + profile model.PlayerProfile + achievements map[string]bool +} + +func newMockGameRepo() *mockGameRepo { + return &mockGameRepo{ + profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100}, + achievements: map[string]bool{}, + } +} + +func (m *mockGameRepo) GetProfile(_ context.Context) (*model.PlayerProfile, error) { + cp := m.profile + return &cp, nil +} +func (m *mockGameRepo) AddXP(_ context.Context, _ string, amount int, _ string) (*model.PlayerProfile, error) { + m.profile.XP += amount + for m.profile.XP >= m.profile.XPToNext { + m.profile.XP -= m.profile.XPToNext + m.profile.Level++ + m.profile.XPToNext = m.profile.Level * m.profile.Level * 100 + } + cp := m.profile + return &cp, nil +} +func (m *mockGameRepo) ListActiveQuests(_ context.Context) ([]model.PlayerQuest, error) { + return nil, nil +} +func (m *mockGameRepo) EnsurePlayerQuest(_ context.Context, _ int, _ string) (int, error) { return 1, nil } +func (m *mockGameRepo) IncrementQuestCount(_ context.Context, _, _ string, _ int) error { return nil } +func (m *mockGameRepo) UpdateQuestPct(_ context.Context, _ string, _ float64) error { return nil } +func (m *mockGameRepo) ClaimQuest(_ context.Context, _ int) (int, error) { return 50, nil } +func (m *mockGameRepo) ListAchievements(_ context.Context) ([]model.Achievement, error) { return nil, nil } +func (m *mockGameRepo) IsAchievementUnlocked(_ context.Context, c string) (bool, error) { + return m.achievements[c], nil +} +func (m *mockGameRepo) UnlockAchievement(_ context.Context, c string) (int, error) { + m.achievements[c] = true + return 100, nil +} +func (m *mockGameRepo) ListCosmetics(_ context.Context) ([]model.Cosmetic, error) { return nil, nil } +func (m *mockGameRepo) EquipCosmetic(_ context.Context, _ int) error { return nil } +func (m *mockGameRepo) UnlockCosmeticsForLevel(_ context.Context, _ int) error { return nil } + +func TestGame_XPAccumulates(t *testing.T) { + repo := newMockGameRepo() + svc := service.NewGameService(repo) + + p, err := svc.AwardXP(context.Background(), "test", 50, "test") + if err != nil { + t.Fatal(err) + } + if p.XP != 50 { + t.Errorf("expected xp=50, got %d", p.XP) + } +} + +func TestGame_LevelUpAtThreshold(t *testing.T) { + repo := newMockGameRepo() + svc := service.NewGameService(repo) + + // level 1 threshold is 100 XP + p, err := svc.AwardXP(context.Background(), "test", 100, "test") + if err != nil { + t.Fatal(err) + } + if p.Level != 2 { + t.Errorf("expected level=2 after 100 XP, got %d", p.Level) + } + if p.XP != 0 { + t.Errorf("expected remaining xp=0, got %d", p.XP) + } +} + +func TestGame_AchievementNotDuplicated(t *testing.T) { + repo := newMockGameRepo() + svc := service.NewGameService(repo) + + svc.CheckAndUnlockAchievement(context.Background(), "first_transaction") + svc.CheckAndUnlockAchievement(context.Background(), "first_transaction") + + // second call should be a no-op — XP only awarded once + // repo tracks it as unlocked after first call + if !repo.achievements["first_transaction"] { + t.Error("achievement should be marked unlocked") + } +} diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 09a7e60..b198a92 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -10,6 +10,7 @@ Transações Importar Contas + Personagem Configurações diff --git a/apps/web/src/components/CharacterWidget.vue b/apps/web/src/components/CharacterWidget.vue new file mode 100644 index 0000000..8089596 --- /dev/null +++ b/apps/web/src/components/CharacterWidget.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/apps/web/src/components/XPBar.vue b/apps/web/src/components/XPBar.vue new file mode 100644 index 0000000..4b084ad --- /dev/null +++ b/apps/web/src/components/XPBar.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 1174d3c..680565f 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -29,6 +29,11 @@ const router = createRouter({ name: 'accounts', component: () => import('../views/AccountsView.vue'), }, + { + path: '/personagem', + name: 'character', + component: () => import('../views/CharacterView.vue'), + }, { path: '/configuracoes', name: 'settings', diff --git a/apps/web/src/stores/game.ts b/apps/web/src/stores/game.ts new file mode 100644 index 0000000..d32bf20 --- /dev/null +++ b/apps/web/src/stores/game.ts @@ -0,0 +1,80 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface PlayerProfile { + id: number + level: number + xp: number + xp_to_next: number +} + +export interface PlayerQuest { + id: number + quest_id: number + title: string + quest_type: string + target_count: number | null + target_pct: number | null + xp_reward: number + period: string + current_count: number + current_pct: number + completed: boolean + claimed: boolean +} + +export interface Achievement { + id: number + title: string + description: string + icon: string + xp_reward: number + unlock_condition: string + earned: boolean + earned_at: string +} + +export interface Cosmetic { + id: number + name: string + type: string + unlock_level: number + css_data: string + unlocked: boolean + equipped: boolean +} + +export interface GameSummary { + profile: PlayerProfile + active_quests: PlayerQuest[] + recent_achievements: Achievement[] + equipped_cosmetics: Cosmetic[] +} + +export const useGameStore = defineStore('game', () => { + const summary = ref(null) + const loading = ref(false) + + async function fetchSummary() { + loading.value = true + try { + const res = await fetch('/api/game/summary') + if (!res.ok) throw new Error('failed') + summary.value = await res.json() + } finally { + loading.value = false + } + } + + async function claimQuest(playerQuestId: number) { + await fetch(`/api/game/quests/${playerQuestId}/claim`, { method: 'POST' }) + await fetchSummary() + } + + async function equipCosmetic(cosmeticId: number) { + await fetch(`/api/game/cosmetics/${cosmeticId}/equip`, { method: 'POST' }) + await fetchSummary() + } + + return { summary, loading, fetchSummary, claimQuest, equipCosmetic } +}) diff --git a/apps/web/src/views/CharacterView.vue b/apps/web/src/views/CharacterView.vue new file mode 100644 index 0000000..cedc268 --- /dev/null +++ b/apps/web/src/views/CharacterView.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index 064a4db..238f47a 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -1,11 +1,17 @@