feat: multi-usuário com autenticação JWT
- Tabela `profiles` + coluna `profile_id` em todas as entidades (categories, transactions, recurring_expenses, accounts, player_profile, xp_events, player_quests, player_achievements, player_cosmetics) - Dados existentes migrados para profile_id = 1 (Manoel) - CLI `./api create-user --name <n> --password <p>` cria perfil com seed de categorias e player_profile; faz upsert de senha se já existir - Auth substituída: cookie+APP_PASSWORD → JWT Bearer 24h (HS256) - Middleware RequireAuth injeta profile_id no context de todas as rotas - Todos os repositórios filtram por profile_id do context - Endpoints: POST /api/auth/login, GET /api/auth/me, POST /api/auth/change-password, POST /api/logout - Frontend: auth store usa localStorage (fc_token/fc_profile), api.ts envia Authorization header, LoginView usa campo name - SettingsView reescrita com troca de senha e logout - docker-compose.yml: remove APP_USERNAME/APP_PASSWORD, adiciona JWT_SECRET Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"financeiro-carvalho/internal/middleware"
|
||||
"financeiro-carvalho/internal/model"
|
||||
)
|
||||
|
||||
@@ -18,32 +19,36 @@ func NewGameRepository(pool *pgxpool.Pool) *GameRepository {
|
||||
}
|
||||
|
||||
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 LIMIT 1`).
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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) VALUES ($1, $2, $3)`,
|
||||
eventType, amount, description)
|
||||
_, 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 LIMIT 1`).
|
||||
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
|
||||
@@ -56,7 +61,8 @@ func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int
|
||||
p.XPToNext = xpThreshold(p.Level)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `UPDATE player_profile SET level=$1, xp=$2, xp_to_next=$3 WHERE id=$4`,
|
||||
_, 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
|
||||
@@ -66,6 +72,7 @@ func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -80,14 +87,14 @@ func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQu
|
||||
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
|
||||
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)
|
||||
`, dailyPeriod, weeklyPeriod, monthlyPeriod, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,16 +125,18 @@ func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQu
|
||||
}
|
||||
|
||||
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) VALUES ($1, $2)
|
||||
ON CONFLICT (quest_id, period) DO UPDATE SET quest_id = EXCLUDED.quest_id
|
||||
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).Scan(&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,
|
||||
@@ -137,13 +146,15 @@ func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, per
|
||||
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)
|
||||
`, 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,
|
||||
@@ -153,33 +164,36 @@ func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct
|
||||
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)
|
||||
`, 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.completed AND NOT pq.claimed
|
||||
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).Scan(&xpReward)
|
||||
`, 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,
|
||||
pa.id IS NOT NULL,
|
||||
COALESCE(pa.earned_at::text, '')
|
||||
FROM achievements a
|
||||
LEFT JOIN player_achievements pa ON pa.achievement_id = a.id
|
||||
LEFT JOIN player_achievements pa ON pa.achievement_id = a.id AND pa.profile_id = $1
|
||||
ORDER BY pa.earned_at DESC NULLS LAST, a.id
|
||||
`)
|
||||
`, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,38 +211,42 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem
|
||||
}
|
||||
|
||||
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
|
||||
`, condition).Scan(&count)
|
||||
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)
|
||||
SELECT id FROM achievements WHERE unlock_condition = $1
|
||||
ON CONFLICT (achievement_id) DO NOTHING
|
||||
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).Scan(&xpReward)
|
||||
`, 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 LIMIT 1`).Scan(&level)
|
||||
_ = 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
|
||||
LEFT JOIN player_cosmetics pc ON pc.cosmetic_id = c.id AND pc.profile_id = $2
|
||||
ORDER BY c.unlock_level, c.id
|
||||
`, level)
|
||||
`, level, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -246,26 +264,27 @@ func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, e
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
AND pc.profile_id = $2
|
||||
`, cosmeticID, pid)
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -273,10 +292,11 @@ func (r *GameRepository) EquipCosmetic(ctx context.Context, cosmeticID int) erro
|
||||
}
|
||||
|
||||
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)
|
||||
SELECT id FROM cosmetics WHERE unlock_level <= $1
|
||||
ON CONFLICT (cosmetic_id) DO NOTHING
|
||||
`, level)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user