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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user