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:
2026-05-27 20:04:44 -03:00
co-authored by Claude Sonnet 4.6
parent 50637bd590
commit acbce4edc0
49 changed files with 2662 additions and 382 deletions
+22 -11
View File
@@ -5,6 +5,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -28,6 +29,7 @@ func NewAccountRepository(pool *pgxpool.Pool) *AccountRepository {
}
func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, `
SELECT
a.id, a.name, a.type, a.initial_balance,
@@ -40,9 +42,10 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
AS balance
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.id
WHERE a.profile_id = $1
GROUP BY a.id
ORDER BY a.created_at
`)
`, pid)
if err != nil {
return nil, err
}
@@ -60,6 +63,7 @@ func (r *AccountRepository) List(ctx context.Context) ([]model.Account, error) {
}
func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var a model.Account
err := r.pool.QueryRow(ctx, `
SELECT
@@ -73,9 +77,9 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
AS balance
FROM accounts a
LEFT JOIN transactions t ON t.account_id = a.id
WHERE a.id = $1
WHERE a.id = $1 AND a.profile_id = $2
GROUP BY a.id
`, id).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance)
`, id, pid).Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt, &a.Balance)
if err != nil {
return nil, err
}
@@ -83,16 +87,17 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int) (*model.Account
}
func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (*model.Account, error) {
pid := middleware.ProfileIDFromCtx(ctx)
yt := in.YieldType
if yt == "" {
yt = "none"
}
var a model.Account
err := r.pool.QueryRow(ctx, `
INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO accounts (name, type, initial_balance, yield_type, closing_day, due_day, profile_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text
`, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay).
`, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, pid).
Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
@@ -102,15 +107,16 @@ func (r *AccountRepository) Create(ctx context.Context, in model.AccountInput) (
}
func (r *AccountRepository) Update(ctx context.Context, id int, in model.AccountInput) (*model.Account, error) {
pid := middleware.ProfileIDFromCtx(ctx)
yt := in.YieldType
if yt == "" {
yt = "none"
}
row := r.pool.QueryRow(ctx, `
UPDATE accounts SET name=$1, type=$2, initial_balance=$3, yield_type=$4, closing_day=$5, due_day=$6, updated_at=NOW()
WHERE id=$7
WHERE id=$7 AND profile_id=$8
RETURNING id, name, type, initial_balance, yield_type, last_yield_date::text, closing_day, due_day, created_at::text, updated_at::text
`, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id)
`, in.Name, in.Type, in.InitialBalance, yt, in.ClosingDay, in.DueDay, id, pid)
var a model.Account
if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.InitialBalance, &a.YieldType, &a.LastYieldDate, &a.ClosingDay, &a.DueDay, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
@@ -123,11 +129,13 @@ func (r *AccountRepository) Update(ctx context.Context, id int, in model.Account
}
func (r *AccountRepository) Delete(ctx context.Context, id int) error {
_, err := r.pool.Exec(ctx, `DELETE FROM accounts WHERE id = $1`, id)
pid := middleware.ProfileIDFromCtx(ctx)
_, err := r.pool.Exec(ctx, `DELETE FROM accounts WHERE id = $1 AND profile_id = $2`, id, pid)
return err
}
func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var total float64
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(
@@ -138,11 +146,14 @@ func (r *AccountRepository) TotalPatrimony(ctx context.Context) (float64, error)
), 0)
), 0)
FROM accounts a
`).Scan(&total)
WHERE a.profile_id = $1
`, pid).Scan(&total)
return total, err
}
func (r *AccountRepository) UpdateLastYieldDate(ctx context.Context, id int, date string) error {
_, err := r.pool.Exec(ctx, `UPDATE accounts SET last_yield_date = $1 WHERE id = $2`, date, id)
pid := middleware.ProfileIDFromCtx(ctx)
_, err := r.pool.Exec(ctx,
`UPDATE accounts SET last_yield_date = $1 WHERE id = $2 AND profile_id = $3`, date, id, pid)
return err
}
+17 -9
View File
@@ -7,6 +7,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -28,10 +29,12 @@ 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
FROM categories
ORDER BY is_default DESC, name ASC`)
WHERE profile_id = $1
ORDER BY is_default DESC, name ASC`, pid)
if err != nil {
return nil, err
}
@@ -49,10 +52,11 @@ func (r *categoryRepo) List(ctx context.Context) ([]model.Category, error) {
}
func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, error) {
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
FROM categories WHERE id = $1`, id).
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)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -61,24 +65,26 @@ func (r *categoryRepo) GetByID(ctx context.Context, id int) (*model.Category, er
}
func (r *categoryRepo) Create(ctx context.Context, name, color string, isTax bool) (*model.Category, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var c model.Category
err := r.db.QueryRow(ctx, `
INSERT INTO categories (name, color, is_tax)
VALUES ($1, $2, $3)
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).
name, color, isTax, pid).
Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt)
return &c, err
}
func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, isTax bool) (*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
WHERE id = $4 AND profile_id = $5
RETURNING id, name, color, is_default, is_tax, created_at, updated_at`,
name, color, isTax, id).
name, color, isTax, id, pid).
Scan(&c.ID, &c.Name, &c.Color, &c.IsDefault, &c.IsTax, &c.CreatedAt, &c.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -87,7 +93,8 @@ func (r *categoryRepo) Update(ctx context.Context, id int, name, color string, i
}
func (r *categoryRepo) Delete(ctx context.Context, id int) error {
tag, err := r.db.Exec(ctx, `DELETE FROM categories WHERE id = $1`, id)
pid := middleware.ProfileIDFromCtx(ctx)
tag, err := r.db.Exec(ctx, `DELETE FROM categories WHERE id = $1 AND profile_id = $2`, id, pid)
if err != nil {
return err
}
@@ -98,9 +105,10 @@ func (r *categoryRepo) Delete(ctx context.Context, id int) error {
}
func (r *categoryRepo) HasTransactions(ctx context.Context, id int) (bool, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var count int
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM transactions WHERE category_id = $1`, id).
`SELECT COUNT(*) FROM transactions WHERE category_id = $1 AND profile_id = $2`, id, pid).
Scan(&count)
return count > 0, err
}
+12 -7
View File
@@ -7,6 +7,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -24,6 +25,7 @@ func NewCreditBillRepository(db *pgxpool.Pool) CreditBillRepository {
}
func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]model.CreditBill, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.db.Query(ctx, `
SELECT
cb.id, cb.account_id, a.name,
@@ -31,14 +33,14 @@ func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]mo
COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total,
cb.paid, cb.paid_at::text, cb.payment_account_id
FROM credit_bills cb
JOIN accounts a ON a.id = cb.account_id
JOIN accounts a ON a.id = cb.account_id AND a.profile_id = $2
LEFT JOIN transactions t ON t.account_id = cb.account_id
AND t.date >= cb.period_start AND t.date <= cb.period_end
AND t.type = 'expense'
WHERE cb.account_id = $1
GROUP BY cb.id, a.name
ORDER BY cb.period_start DESC
`, accountID)
`, accountID, pid)
if err != nil {
return nil, err
}
@@ -55,6 +57,7 @@ func (r *creditBillRepo) ListByAccount(ctx context.Context, accountID int) ([]mo
}
func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model.CreditBill, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var b model.CreditBill
err := r.db.QueryRow(ctx, `
SELECT
@@ -63,7 +66,7 @@ func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model.
COALESCE(SUM(t.amount) FILTER (WHERE t.type = 'expense'), 0) AS total,
cb.paid, cb.paid_at::text, cb.payment_account_id
FROM credit_bills cb
JOIN accounts a ON a.id = cb.account_id
JOIN accounts a ON a.id = cb.account_id AND a.profile_id = $2
LEFT JOIN transactions t ON t.account_id = cb.account_id
AND t.date >= cb.period_start AND t.date <= cb.period_end
AND t.type = 'expense'
@@ -71,7 +74,7 @@ func (r *creditBillRepo) GetCurrent(ctx context.Context, accountID int) (*model.
GROUP BY cb.id, a.name
ORDER BY cb.period_start DESC
LIMIT 1
`, accountID).Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID)
`, accountID, pid).Scan(&b.ID, &b.AccountID, &b.AccountName, &b.PeriodStart, &b.PeriodEnd, &b.DueDate, &b.Total, &b.Paid, &b.PaidAt, &b.PaymentAccountID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -94,10 +97,12 @@ func (r *creditBillRepo) Upsert(ctx context.Context, in model.CreditBillInput) (
}
func (r *creditBillRepo) MarkPaid(ctx context.Context, id int, paymentAccountID *int) error {
pid := middleware.ProfileIDFromCtx(ctx)
_, err := r.db.Exec(ctx, `
UPDATE credit_bills
UPDATE credit_bills cb
SET paid = TRUE, paid_at = NOW(), payment_account_id = $2
WHERE id = $1
`, id, paymentAccountID)
FROM accounts a
WHERE cb.id = $1 AND cb.account_id = a.id AND a.profile_id = $3
`, id, paymentAccountID, pid)
return err
}
+16 -7
View File
@@ -5,6 +5,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -17,18 +18,20 @@ func NewDashboardRepository(pool *pgxpool.Pool) *DashboardRepository {
}
func (r *DashboardRepository) MonthlySummary(ctx context.Context, month string) (income, expenses float64, err error) {
pid := middleware.ProfileIDFromCtx(ctx)
row := r.pool.QueryRow(ctx, `
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0)
FROM transactions
WHERE to_char(date, 'YYYY-MM') = $1
`, month)
WHERE to_char(date, 'YYYY-MM') = $1 AND profile_id = $2
`, month, pid)
err = row.Scan(&income, &expenses)
return
}
func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]model.CategoryTotal, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, `
SELECT
t.category_id,
@@ -39,9 +42,10 @@ func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]m
LEFT JOIN categories c ON c.id = t.category_id
WHERE to_char(t.date, 'YYYY-MM') = $1
AND t.type = 'expense'
AND t.profile_id = $2
GROUP BY t.category_id, c.name, c.color
ORDER BY total DESC
`, month)
`, month, pid)
if err != nil {
return nil, err
}
@@ -59,6 +63,7 @@ func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]m
}
func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string) ([]model.MonthEvolution, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, `
SELECT
to_char(m.ms, 'YYYY-MM') AS month,
@@ -69,10 +74,10 @@ func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string
date_trunc('month', ($1 || '-01')::date),
'1 month'::interval
) AS m(ms)
LEFT JOIN transactions t ON date_trunc('month', t.date) = m.ms
LEFT JOIN transactions t ON date_trunc('month', t.date) = m.ms AND t.profile_id = $2
GROUP BY m.ms
ORDER BY m.ms
`, month)
`, month, pid)
if err != nil {
return nil, err
}
@@ -91,6 +96,7 @@ func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string
}
func (r *DashboardRepository) TotalTaxes(ctx context.Context, month string) (float64, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var total float64
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(t.amount), 0)
@@ -99,11 +105,13 @@ func (r *DashboardRepository) TotalTaxes(ctx context.Context, month string) (flo
WHERE to_char(t.date, 'YYYY-MM') = $1
AND t.type = 'expense'
AND c.is_tax = TRUE
`, month).Scan(&total)
AND t.profile_id = $2
`, month, pid).Scan(&total)
return total, err
}
func (r *DashboardRepository) RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, `
SELECT
t.id,
@@ -115,9 +123,10 @@ func (r *DashboardRepository) RecentTransactions(ctx context.Context, month stri
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
WHERE to_char(t.date, 'YYYY-MM') = $1
AND t.profile_id = $2
ORDER BY t.date DESC, t.id DESC
LIMIT 10
`, month)
`, month, pid)
if err != nil {
return nil, err
}
+55 -35
View File
@@ -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
}
+18 -8
View File
@@ -7,6 +7,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -31,9 +32,10 @@ func NewRecurringRepository(db *pgxpool.Pool) RecurringRepository {
}
func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.db.Query(ctx, `
SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at
FROM recurring_expenses ORDER BY name ASC`)
FROM recurring_expenses WHERE profile_id = $1 ORDER BY name ASC`, pid)
if err != nil {
return nil, err
}
@@ -50,10 +52,11 @@ func (r *recurringRepo) List(ctx context.Context) ([]model.RecurringExpense, err
}
func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringExpense, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var re model.RecurringExpense
err := r.db.QueryRow(ctx, `
SELECT id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at
FROM recurring_expenses WHERE id = $1`, id).
FROM recurring_expenses WHERE id = $1 AND profile_id = $2`, id, pid).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -62,21 +65,23 @@ func (r *recurringRepo) GetByID(ctx context.Context, id int) (*model.RecurringEx
}
func (r *recurringRepo) Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error) {
pid := middleware.ProfileIDFromCtx(ctx)
t := in.Type
if t == "" {
t = "expense"
}
var re model.RecurringExpense
err := r.db.QueryRow(ctx, `
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO recurring_expenses (name, expected_amount, day_of_month, category_id, type, profile_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t).
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, pid).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
return &re, err
}
func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error) {
pid := middleware.ProfileIDFromCtx(ctx)
t := in.Type
if t == "" {
t = "expense"
@@ -85,9 +90,9 @@ func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringIn
err := r.db.QueryRow(ctx, `
UPDATE recurring_expenses
SET name = $1, expected_amount = $2, day_of_month = $3, category_id = $4, type = $5, updated_at = NOW()
WHERE id = $6
WHERE id = $6 AND profile_id = $7
RETURNING id, name, expected_amount, day_of_month, category_id, type, active, created_at, updated_at`,
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, id).
in.Name, in.ExpectedAmount, in.DayOfMonth, in.CategoryID, t, id, pid).
Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -96,7 +101,8 @@ func (r *recurringRepo) Update(ctx context.Context, id int, in model.RecurringIn
}
func (r *recurringRepo) Delete(ctx context.Context, id int) error {
tag, err := r.db.Exec(ctx, `DELETE FROM recurring_expenses WHERE id = $1`, id)
pid := middleware.ProfileIDFromCtx(ctx)
tag, err := r.db.Exec(ctx, `DELETE FROM recurring_expenses WHERE id = $1 AND profile_id = $2`, id, pid)
if err != nil {
return err
}
@@ -106,6 +112,10 @@ func (r *recurringRepo) Delete(ctx context.Context, id int) error {
return nil
}
// IsIgnored, Ignore, Unignore, IsLate, MarkLate, UnmarkLate operate on recurring_ignores/recurring_late
// which are scoped via FK to recurring_expenses (already profile-scoped). The caller (service) verifies
// ownership via GetByID before reaching these methods.
func (r *recurringRepo) IsIgnored(ctx context.Context, id int, month string) (bool, string, error) {
var reason string
err := r.db.QueryRow(ctx,
+11 -7
View File
@@ -6,6 +6,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -22,17 +23,17 @@ func NewTransactionRepository(db *pgxpool.Pool) TransactionRepository {
return &transactionRepo{db: db}
}
// normalizeDesc lowercases and collapses whitespace for fuzzy dedup.
func normalizeDesc(s string) string {
return strings.ToLower(strings.Join(strings.Fields(s), " "))
}
func (r *transactionRepo) IsDuplicate(ctx context.Context, date, description string, amount float64) (bool, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions
WHERE date = $1 AND amount = $2 AND LOWER(description) = $3`,
date, amount, normalizeDesc(description)).Scan(&count)
WHERE date = $1 AND amount = $2 AND LOWER(description) = $3 AND profile_id = $4`,
date, amount, normalizeDesc(description), pid).Scan(&count)
return count > 0, err
}
@@ -40,13 +41,16 @@ func (r *transactionRepo) IsExternalIDKnown(ctx context.Context, externalID stri
if externalID == "" {
return false, nil
}
pid := middleware.ProfileIDFromCtx(ctx)
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions WHERE external_id = $1`, externalID).Scan(&count)
SELECT COUNT(*) FROM transactions WHERE external_id = $1 AND profile_id = $2`,
externalID, pid).Scan(&count)
return count > 0, err
}
func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error) {
pid := middleware.ProfileIDFromCtx(ctx)
tx, err := r.db.Begin(ctx)
if err != nil {
return 0, err
@@ -67,9 +71,9 @@ func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow
origDate = &row.OriginalDate
}
_, err := tx.Exec(ctx, `
INSERT INTO transactions (date, original_date, amount, description, type, source, external_id, category_id)
VALUES ($1, $2, $3, $4, $5, 'import', $6, $7)`,
row.Date, origDate, row.Amount, row.Description, row.Type, extID, row.CategoryID)
INSERT INTO transactions (date, original_date, amount, description, type, source, external_id, category_id, profile_id)
VALUES ($1, $2, $3, $4, $5, 'import', $6, $7, $8)`,
row.Date, origDate, row.Amount, row.Description, row.Type, extID, row.CategoryID, pid)
if err != nil {
return count, err
}
@@ -7,6 +7,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
@@ -17,7 +18,6 @@ type ManualTransactionRepository interface {
Update(ctx context.Context, t model.Transaction) (*model.Transaction, error)
Delete(ctx context.Context, id int) error
DeleteByMonth(ctx context.Context, month string) (int, error)
// HasMatchingTransaction checks if a category has a transaction of the given type in the given month.
HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error)
}
@@ -28,12 +28,13 @@ func NewManualTransactionRepository(db *pgxpool.Pool) ManualTransactionRepositor
}
func (r *manualTxRepo) List(ctx context.Context, month string) ([]model.Transaction, error) {
pid := middleware.ProfileIDFromCtx(ctx)
query := `
SELECT id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at
FROM transactions`
args := []any{}
FROM transactions WHERE profile_id = $1`
args := []any{pid}
if month != "" {
query += ` WHERE TO_CHAR(date, 'YYYY-MM') = $1`
query += ` AND TO_CHAR(date, 'YYYY-MM') = $2`
args = append(args, month)
}
query += ` ORDER BY date DESC, id DESC`
@@ -56,10 +57,11 @@ func (r *manualTxRepo) List(ctx context.Context, month string) ([]model.Transact
}
func (r *manualTxRepo) GetByID(ctx context.Context, id int) (*model.Transaction, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var t model.Transaction
err := r.db.QueryRow(ctx, `
SELECT id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at
FROM transactions WHERE id = $1`, id).
FROM transactions WHERE id = $1 AND profile_id = $2`, id, pid).
Scan(&t.ID, &t.Date, &t.Amount, &t.Description, &t.Type, &t.Source, &t.CategoryID, &t.AccountID, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -68,24 +70,26 @@ func (r *manualTxRepo) GetByID(ctx context.Context, id int) (*model.Transaction,
}
func (r *manualTxRepo) Create(ctx context.Context, t model.Transaction) (*model.Transaction, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var out model.Transaction
err := r.db.QueryRow(ctx, `
INSERT INTO transactions (date, amount, description, type, source, category_id, account_id)
VALUES ($1, $2, $3, $4, 'manual', $5, $6)
INSERT INTO transactions (date, amount, description, type, source, category_id, account_id, profile_id)
VALUES ($1, $2, $3, $4, 'manual', $5, $6, $7)
RETURNING id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at`,
t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID).
t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID, pid).
Scan(&out.ID, &out.Date, &out.Amount, &out.Description, &out.Type, &out.Source, &out.CategoryID, &out.AccountID, &out.CreatedAt, &out.UpdatedAt)
return &out, err
}
func (r *manualTxRepo) Update(ctx context.Context, t model.Transaction) (*model.Transaction, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var out model.Transaction
err := r.db.QueryRow(ctx, `
UPDATE transactions
SET date=$1, amount=$2, description=$3, type=$4, category_id=$5, account_id=$6, updated_at=NOW()
WHERE id=$7 AND source='manual'
WHERE id=$7 AND source='manual' AND profile_id=$8
RETURNING id, date::text, amount, description, type, source, category_id, account_id, created_at, updated_at`,
t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID, t.ID).
t.Date, t.Amount, t.Description, t.Type, t.CategoryID, t.AccountID, t.ID, pid).
Scan(&out.ID, &out.Date, &out.Amount, &out.Description, &out.Type, &out.Source, &out.CategoryID, &out.AccountID, &out.CreatedAt, &out.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -94,7 +98,8 @@ func (r *manualTxRepo) Update(ctx context.Context, t model.Transaction) (*model.
}
func (r *manualTxRepo) Delete(ctx context.Context, id int) error {
tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1`, id)
pid := middleware.ProfileIDFromCtx(ctx)
tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE id = $1 AND profile_id = $2`, id, pid)
if err != nil {
return err
}
@@ -105,7 +110,9 @@ func (r *manualTxRepo) Delete(ctx context.Context, id int) error {
}
func (r *manualTxRepo) DeleteByMonth(ctx context.Context, month string) (int, error) {
tag, err := r.db.Exec(ctx, `DELETE FROM transactions WHERE TO_CHAR(date, 'YYYY-MM') = $1`, month)
pid := middleware.ProfileIDFromCtx(ctx)
tag, err := r.db.Exec(ctx,
`DELETE FROM transactions WHERE TO_CHAR(date, 'YYYY-MM') = $1 AND profile_id = $2`, month, pid)
if err != nil {
return 0, err
}
@@ -114,16 +121,17 @@ func (r *manualTxRepo) DeleteByMonth(ctx context.Context, month string) (int, er
func (r *manualTxRepo) HasMatchingTransaction(ctx context.Context, categoryID *int, month string, amount float64, txType string) (bool, error) {
if categoryID == nil {
// No category set — cannot auto-match, always report as uncovered
return false, nil
}
pid := middleware.ProfileIDFromCtx(ctx)
var count int
err := r.db.QueryRow(ctx, `
SELECT COUNT(*) FROM transactions
WHERE category_id = $1
AND TO_CHAR(date, 'YYYY-MM') = $2
AND type = $3
AND amount BETWEEN $4 * 0.9 AND $4 * 1.1`,
*categoryID, month, txType, amount).Scan(&count)
AND amount BETWEEN $4 * 0.9 AND $4 * 1.1
AND profile_id = $5`,
*categoryID, month, txType, amount, pid).Scan(&count)
return count > 0, err
}