Files
carvalho-finances/apps/api/internal/repository/recurring.go
T
Mlcavalho1andClaude Sonnet 4.6 acbce4edc0 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]>
2026-05-27 20:04:44 -03:00

168 lines
6.1 KiB
Go

package repository
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/middleware"
"financeiro-carvalho/internal/model"
)
type RecurringRepository interface {
List(ctx context.Context) ([]model.RecurringExpense, error)
GetByID(ctx context.Context, id int) (*model.RecurringExpense, error)
Create(ctx context.Context, in model.RecurringInput) (*model.RecurringExpense, error)
Update(ctx context.Context, id int, in model.RecurringInput) (*model.RecurringExpense, error)
Delete(ctx context.Context, id int) error
IsIgnored(ctx context.Context, id int, month string) (bool, string, error)
Ignore(ctx context.Context, id int, month, reason string) error
Unignore(ctx context.Context, id int, month string) error
IsLate(ctx context.Context, id int, month string) (bool, error)
MarkLate(ctx context.Context, id int, month string) error
UnmarkLate(ctx context.Context, id int, month string) error
}
type recurringRepo struct{ db *pgxpool.Pool }
func NewRecurringRepository(db *pgxpool.Pool) RecurringRepository {
return &recurringRepo{db: db}
}
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 WHERE profile_id = $1 ORDER BY name ASC`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.RecurringExpense
for rows.Next() {
var re model.RecurringExpense
if err := rows.Scan(&re.ID, &re.Name, &re.ExpectedAmount, &re.DayOfMonth, &re.CategoryID, &re.Type, &re.Active, &re.CreatedAt, &re.UpdatedAt); err != nil {
return nil, err
}
out = append(out, re)
}
return out, rows.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 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
}
return &re, err
}
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, 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, 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"
}
var re model.RecurringExpense
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 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, 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
}
return &re, err
}
func (r *recurringRepo) Delete(ctx context.Context, id int) error {
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
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
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,
`SELECT COALESCE(reason, '') FROM recurring_ignores WHERE recurring_id = $1 AND month = $2`,
id, month).Scan(&reason)
if errors.Is(err, pgx.ErrNoRows) {
return false, "", nil
}
return err == nil, reason, err
}
func (r *recurringRepo) Ignore(ctx context.Context, id int, month, reason string) error {
_, err := r.db.Exec(ctx, `
INSERT INTO recurring_ignores (recurring_id, month, reason)
VALUES ($1, $2, $3)
ON CONFLICT (recurring_id, month) DO UPDATE SET reason = $3`,
id, month, reason)
return err
}
func (r *recurringRepo) Unignore(ctx context.Context, id int, month string) error {
_, err := r.db.Exec(ctx, `DELETE FROM recurring_ignores WHERE recurring_id = $1 AND month = $2`, id, month)
return err
}
func (r *recurringRepo) IsLate(ctx context.Context, id int, month string) (bool, error) {
var count int
err := r.db.QueryRow(ctx,
`SELECT COUNT(*) FROM recurring_late WHERE recurring_id = $1 AND month = $2`,
id, month).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *recurringRepo) MarkLate(ctx context.Context, id int, month string) error {
_, err := r.db.Exec(ctx, `
INSERT INTO recurring_late (recurring_id, month)
VALUES ($1, $2)
ON CONFLICT (recurring_id, month) DO NOTHING`,
id, month)
return err
}
func (r *recurringRepo) UnmarkLate(ctx context.Context, id int, month string) error {
_, err := r.db.Exec(ctx, `DELETE FROM recurring_late WHERE recurring_id = $1 AND month = $2`, id, month)
return err
}