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
+13 -2
View File
@@ -40,6 +40,12 @@ func main() {
log.Fatalf("migration: %v", err)
}
// CLI subcommand: create-user
if len(os.Args) > 1 && os.Args[1] == "create-user" {
authmw.RunCreateUser(pool)
return
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
@@ -83,12 +89,17 @@ func main() {
r.Get("/health", handler.Health)
r.Post("/api/login", authmw.LoginHandler)
// Public auth endpoints
r.Post("/api/auth/login", authmw.LoginHandler(pool))
r.Post("/api/logout", authmw.LogoutHandler)
r.Get("/api/auth/me", authmw.MeHandler)
// Protected routes
r.Route("/api", func(r chi.Router) {
r.Use(authmw.RequireAuth)
r.Get("/auth/me", authmw.MeHandler)
r.Post("/auth/change-password", authmw.ChangePasswordHandler(pool))
r.Get("/categories", categoryHandler.List)
r.Post("/categories", categoryHandler.Create)
r.Put("/categories/{id}", categoryHandler.Update)
+1
View File
@@ -9,6 +9,7 @@ require (
)
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+254 -80
View File
@@ -1,51 +1,38 @@
package middleware
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"financeiro-carvalho/internal/model"
)
const cookieName = "cf_session"
const sessionDuration = 30 * 24 * time.Hour
type contextKey int
func secret() []byte {
// Use APP_PASSWORD as signing secret so tokens survive container restarts.
return []byte(os.Getenv("APP_PASSWORD"))
}
const (
profileIDKey contextKey = 0
profileNameKey contextKey = 1
)
func signToken(expiry int64) string {
nonce := make([]byte, 8)
rand.Read(nonce)
payload := fmt.Sprintf("%s.%d", hex.EncodeToString(nonce), expiry)
mac := hmac.New(sha256.New, secret())
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return payload + "." + sig
}
const tokenDuration = 24 * time.Hour
func validToken(token string) bool {
parts := strings.SplitN(token, ".", 3)
if len(parts) != 3 {
return false
func jwtSecret() []byte {
s := os.Getenv("JWT_SECRET")
if s == "" {
s = "change-me-in-production"
}
payload := parts[0] + "." + parts[1]
expiry, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().Unix() > expiry {
return false
}
mac := hmac.New(sha256.New, secret())
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(parts[2]), []byte(expected))
return []byte(s)
}
func jsonErr(w http.ResponseWriter, status int, msg string) {
@@ -54,66 +41,253 @@ func jsonErr(w http.ResponseWriter, status int, msg string) {
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// ProfileIDFromCtx extracts the authenticated profile ID from the request context.
func ProfileIDFromCtx(ctx context.Context) int {
v, _ := ctx.Value(profileIDKey).(int)
return v
}
// ProfileNameFromCtx extracts the authenticated profile name from the request context.
func ProfileNameFromCtx(ctx context.Context) string {
v, _ := ctx.Value(profileNameKey).(string)
return v
}
type claims struct {
ProfileID int `json:"pid"`
ProfileName string `json:"pname"`
jwt.RegisteredClaims
}
func issueToken(profileID int, name string) (string, error) {
c := claims{
ProfileID: profileID,
ProfileName: name,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(tokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(jwtSecret())
}
func parseToken(tokenStr string) (*claims, error) {
tok, err := jwt.ParseWithClaims(tokenStr, &claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret(), nil
})
if err != nil || !tok.Valid {
return nil, errors.New("invalid token")
}
c, ok := tok.Claims.(*claims)
if !ok {
return nil, errors.New("invalid claims")
}
return c, nil
}
// RequireAuth validates the Bearer JWT and injects profile_id + profile_name into context.
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName)
if err != nil || !validToken(cookie.Value) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
jsonErr(w, http.StatusUnauthorized, "unauthorized")
return
}
next.ServeHTTP(w, r)
c, err := parseToken(strings.TrimPrefix(auth, "Bearer "))
if err != nil {
jsonErr(w, http.StatusUnauthorized, "unauthorized")
return
}
ctx := context.WithValue(r.Context(), profileIDKey, c.ProfileID)
ctx = context.WithValue(ctx, profileNameKey, c.ProfileName)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func LoginHandler(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, http.StatusBadRequest, "corpo inválido")
return
}
// LoginHandler handles POST /api/auth/login
func LoginHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, http.StatusBadRequest, "corpo inválido")
return
}
wantUser := os.Getenv("APP_USERNAME")
wantPass := os.Getenv("APP_PASSWORD")
if wantUser == "" || body.Username != wantUser || body.Password != wantPass {
jsonErr(w, http.StatusUnauthorized, "credenciais inválidas")
return
var p model.Profile
err := pool.QueryRow(r.Context(),
`SELECT id, name, password_hash FROM profiles WHERE name = $1`, body.Name).
Scan(&p.ID, &p.Name, &p.PasswordHash)
if err != nil || p.PasswordHash == "" {
jsonErr(w, http.StatusUnauthorized, "credenciais inválidas")
return
}
if bcrypt.CompareHashAndPassword([]byte(p.PasswordHash), []byte(body.Password)) != nil {
jsonErr(w, http.StatusUnauthorized, "credenciais inválidas")
return
}
token, err := issueToken(p.ID, p.Name)
if err != nil {
jsonErr(w, http.StatusInternalServerError, "erro interno")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": token,
"profile": map[string]any{"id": p.ID, "name": p.Name},
})
}
expiry := time.Now().Add(sessionDuration).Unix()
token := signToken(expiry)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionDuration.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
})
w.WriteHeader(http.StatusNoContent)
}
// MeHandler handles GET /api/auth/me (requires auth middleware)
func MeHandler(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(cookieName)
if err != nil || !validToken(cookie.Value) {
jsonErr(w, http.StatusUnauthorized, "unauthorized")
return
id := ProfileIDFromCtx(r.Context())
name := ProfileNameFromCtx(r.Context())
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"id": id, "name": name})
}
// ChangePasswordHandler handles POST /api/auth/change-password (requires auth middleware)
func ChangePasswordHandler(pool *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
profileID := ProfileIDFromCtx(r.Context())
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, http.StatusBadRequest, "corpo inválido")
return
}
if len(body.NewPassword) < 8 {
jsonErr(w, http.StatusBadRequest, "nova senha deve ter pelo menos 8 caracteres")
return
}
var hash string
if err := pool.QueryRow(r.Context(),
`SELECT password_hash FROM profiles WHERE id = $1`, profileID).Scan(&hash); err != nil {
jsonErr(w, http.StatusInternalServerError, "erro interno")
return
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body.CurrentPassword)) != nil {
jsonErr(w, http.StatusBadRequest, "senha atual incorreta")
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), 12)
if err != nil {
jsonErr(w, http.StatusInternalServerError, "erro interno")
return
}
if _, err := pool.Exec(r.Context(),
`UPDATE profiles SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
string(newHash), profileID); err != nil {
jsonErr(w, http.StatusInternalServerError, "erro interno")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
}
// LogoutHandler — JWT is stateless; client drops the token.
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// CreateUser creates or updates a profile with hashed password and seeds data for new profiles.
// Used by the CLI subcommand.
func CreateUser(ctx context.Context, pool *pgxpool.Pool, name, password string) (*model.Profile, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, err
}
var p model.Profile
var isNew bool
err = pool.QueryRow(ctx, `SELECT id, name FROM profiles WHERE name = $1`, name).Scan(&p.ID, &p.Name)
if err != nil {
// Profile doesn't exist — create it
err = pool.QueryRow(ctx,
`INSERT INTO profiles (name, password_hash) VALUES ($1, $2) RETURNING id, name`,
name, string(hash)).Scan(&p.ID, &p.Name)
if err != nil {
return nil, err
}
isNew = true
} else {
// Profile exists — update password only
if _, err = pool.Exec(ctx,
`UPDATE profiles SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
string(hash), p.ID); err != nil {
return nil, err
}
}
if isNew {
if err := seedCategories(ctx, pool, p.ID); err != nil {
return nil, err
}
if _, err := pool.Exec(ctx,
`INSERT INTO player_profile (level, xp, xp_to_next, profile_id) VALUES (1, 0, 100, $1)`,
p.ID); err != nil {
return nil, err
}
}
return &p, nil
}
func seedCategories(ctx context.Context, pool *pgxpool.Pool, profileID int) error {
cats := []struct{ name, color string }{
{"Saúde / Insulina", "#EF4444"},
{"Veleiro", "#3B82F6"},
{"Jogos de Tabuleiro", "#8B5CF6"},
{"Alimentação", "#F59E0B"},
{"Lazer", "#10B981"},
{"Freelance", "#6366F1"},
{"Outros", "#6B7280"},
}
for _, c := range cats {
if _, err := pool.Exec(ctx,
`INSERT INTO categories (name, color, is_default, profile_id) VALUES ($1, $2, true, $3)`,
c.name, c.color, profileID); err != nil {
return err
}
}
return nil
}
// RunCreateUser is the CLI entry point for the create-user subcommand.
func RunCreateUser(pool *pgxpool.Pool) {
fs := flag.NewFlagSet("create-user", flag.ExitOnError)
name := fs.String("name", "", "profile name (required)")
password := fs.String("password", "", "password (required)")
fs.Parse(os.Args[2:])
if *name == "" || *password == "" {
fmt.Fprintln(os.Stderr, "usage: ./api create-user --name <name> --password <password>")
os.Exit(1)
}
if len(*password) < 8 {
fmt.Fprintln(os.Stderr, "error: password must be at least 8 characters")
os.Exit(1)
}
ctx := context.Background()
p, err := CreateUser(ctx, pool, *name, *password)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("ok: profile id=%d name=%s\n", p.ID, p.Name)
}
+4 -1
View File
@@ -47,6 +47,9 @@ var m012 string
//go:embed sql/013_transaction_original_date.sql
var m013 string
//go:embed sql/014_profiles.sql
var m014 string
func Run(ctx context.Context, pool *pgxpool.Pool) error {
// Bootstrap: ensure schema_migrations table exists before checking versions.
if _, err := pool.Exec(ctx, `
@@ -58,7 +61,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
return fmt.Errorf("bootstrap schema_migrations: %w", err)
}
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013}
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014}
for i, sql := range migrations {
version := i + 1
var applied bool
@@ -0,0 +1,47 @@
-- Multi-user support: profiles table + profile_id in all per-user entities
CREATE TABLE IF NOT EXISTS profiles (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(72) NOT NULL DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Seed profile 1 for the existing data owner (Manoel)
INSERT INTO profiles (id, name, password_hash) VALUES (1, 'manoel', '')
ON CONFLICT DO NOTHING;
SELECT setval('profiles_id_seq', GREATEST(1, (SELECT MAX(id) FROM profiles)));
-- Add profile_id to all per-user tables (DEFAULT 1 makes existing rows belong to Manoel)
ALTER TABLE categories ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE recurring_expenses ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE player_profile ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE xp_events ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE player_quests ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE player_achievements ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
ALTER TABLE player_cosmetics ADD COLUMN IF NOT EXISTS profile_id INTEGER NOT NULL DEFAULT 1 REFERENCES profiles(id);
-- Fix unique constraints to be scoped per profile
ALTER TABLE player_quests
DROP CONSTRAINT IF EXISTS player_quests_quest_id_period_key;
ALTER TABLE player_quests
ADD CONSTRAINT player_quests_quest_id_period_profile_key
UNIQUE (quest_id, period, profile_id);
ALTER TABLE player_achievements
DROP CONSTRAINT IF EXISTS player_achievements_achievement_id_key;
ALTER TABLE player_achievements
ADD CONSTRAINT player_achievements_achievement_id_profile_key
UNIQUE (achievement_id, profile_id);
ALTER TABLE player_cosmetics
DROP CONSTRAINT IF EXISTS player_cosmetics_cosmetic_id_key;
ALTER TABLE player_cosmetics
ADD CONSTRAINT player_cosmetics_cosmetic_id_profile_key
UNIQUE (cosmetic_id, profile_id);
INSERT INTO schema_migrations (version) VALUES (14) ON CONFLICT DO NOTHING;
+7
View File
@@ -0,0 +1,7 @@
package model
type Profile struct {
ID int
Name string
PasswordHash string
}
+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
}
+2 -1
View File
@@ -50,7 +50,8 @@ const navItems = [
{ to: '/contas', label: 'CONTAS' },
{ to: '/personagem', label: 'PERS.' },
{ to: '/recorrencias', label: 'REC.' },
{ to: '/categorias', label: 'CFG' },
{ to: '/categorias', label: 'CAT.' },
{ to: '/configuracoes', label: 'CFG' },
]
</script>
+2 -1
View File
@@ -48,7 +48,8 @@ const router = createRouter({
},
{
path: '/configuracoes',
redirect: '/categorias',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
},
],
})
+12 -1
View File
@@ -2,13 +2,24 @@ import router from '@/router'
const BASE = '/api'
function getToken(): string | null {
return localStorage.getItem('fc_token')
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {}
if (body) headers['Content-Type'] = 'application/json'
const token = getToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (res.status === 401) {
localStorage.removeItem('fc_token')
localStorage.removeItem('fc_profile')
router.push({ name: 'login' })
throw new Error('Sessão expirada')
}
+68 -18
View File
@@ -1,41 +1,91 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, computed } from 'vue'
const TOKEN_KEY = 'fc_token'
const PROFILE_KEY = 'fc_profile'
interface Profile {
id: number
name: string
}
export const useAuthStore = defineStore('auth', () => {
const checked = ref(false)
const authenticated = ref(false)
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
const profile = ref<Profile | null>(
(() => {
try {
const raw = localStorage.getItem(PROFILE_KEY)
return raw ? (JSON.parse(raw) as Profile) : null
} catch {
return null
}
})(),
)
const isAuthenticated = computed(() => token.value !== null && profile.value !== null)
async function check(): Promise<boolean> {
if (checked.value) return authenticated.value
if (!token.value) return false
try {
const res = await fetch('/api/auth/me')
authenticated.value = res.status === 204
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token.value}` },
})
if (res.ok) {
const data = await res.json()
profile.value = data
return true
}
} catch {
authenticated.value = false
// network error — keep cached state
return isAuthenticated.value
}
checked.value = true
return authenticated.value
_clear()
return false
}
async function login(username: string, password: string): Promise<void> {
const res = await fetch('/api/login', {
async function login(name: string, password: string): Promise<void> {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ name, password }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error ?? 'Erro ao fazer login')
}
authenticated.value = true
checked.value = true
const data = await res.json()
token.value = data.token
profile.value = data.profile
localStorage.setItem(TOKEN_KEY, data.token)
localStorage.setItem(PROFILE_KEY, JSON.stringify(data.profile))
}
async function logout(): Promise<void> {
await fetch('/api/logout', { method: 'POST' })
authenticated.value = false
checked.value = false
await fetch('/api/logout', { method: 'POST' }).catch(() => {})
_clear()
}
return { checked, authenticated, check, login, logout }
async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token.value}`,
},
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error ?? 'Erro ao trocar senha')
}
}
function _clear() {
token.value = null
profile.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(PROFILE_KEY)
}
return { token, profile, isAuthenticated, check, login, logout, changePassword }
})
+3 -3
View File
@@ -6,7 +6,7 @@ import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const name = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
@@ -15,7 +15,7 @@ async function submit() {
error.value = ''
loading.value = true
try {
await auth.login(username.value, password.value)
await auth.login(name.value, password.value)
router.push('/')
} catch (e: any) {
error.value = e.message ?? 'Erro desconhecido'
@@ -37,7 +37,7 @@ async function submit() {
<div class="field">
<label class="fc-pixel field-label">USUÁRIO</label>
<input
v-model="username"
v-model="name"
type="text"
class="login-input"
autocomplete="username"
+94 -174
View File
@@ -1,138 +1,104 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRecurringStore } from '@/stores/recurring'
import { useCategoriesStore } from '@/stores/categories'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import NeonPanel from '@/components/NeonPanel.vue'
const store = useRecurringStore()
const catStore = useCategoriesStore()
const router = useRouter()
const auth = useAuthStore()
onMounted(() => {
store.fetchAll()
catStore.fetchAll()
})
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const pwError = ref<string | null>(null)
const pwSuccess = ref(false)
const pwLoading = ref(false)
const blank = () => ({ name: '', expected_amount: 0, day_of_month: 1, category_id: null as number | null })
const form = ref(blank())
const editId = ref<number | null>(null)
const amountRaw = ref('')
const formError = ref<string | null>(null)
function parseAmount(s: string) {
return parseFloat(s.replace(/\./g, '').replace(',', '.')) || 0
}
function startEdit(id: number) {
const item = store.items.find((x) => x.id === id)
if (!item) return
editId.value = id
form.value = { name: item.name, expected_amount: item.expected_amount, day_of_month: item.day_of_month, category_id: item.category_id }
amountRaw.value = item.expected_amount.toLocaleString('pt-BR', { minimumFractionDigits: 2 })
formError.value = null
}
function cancelEdit() {
editId.value = null
form.value = blank()
amountRaw.value = ''
formError.value = null
}
async function submit() {
formError.value = null
form.value.expected_amount = parseAmount(amountRaw.value)
async function changePassword() {
pwError.value = null
pwSuccess.value = false
if (newPassword.value.length < 8) {
pwError.value = 'Nova senha deve ter pelo menos 8 caracteres'
return
}
if (newPassword.value !== confirmPassword.value) {
pwError.value = 'Senhas não conferem'
return
}
pwLoading.value = true
try {
if (editId.value !== null) {
await store.update(editId.value, form.value)
cancelEdit()
} else {
await store.create(form.value)
form.value = blank()
amountRaw.value = ''
}
await auth.changePassword(currentPassword.value, newPassword.value)
pwSuccess.value = true
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
} catch (e: any) {
formError.value = e.message
pwError.value = e.message ?? 'Erro ao trocar senha'
} finally {
pwLoading.value = false
}
}
async function remove(id: number, name: string) {
if (!confirm(`Excluir recorrência "${name}"?`)) return
await store.remove(id)
}
function catName(id: number | null) {
if (!id) return '—'
return catStore.categories.find((c) => c.id === id)?.name ?? '—'
}
function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
async function logout() {
await auth.logout()
router.push({ name: 'login' })
}
</script>
<template>
<div class="fc-view">
<span class="fc-pixel fc-view__title">:: RECORRÊNCIAS</span>
<span class="fc-pixel fc-view__title">:: CONFIGURAÇÕES</span>
<!-- Form -->
<NeonPanel :title="editId !== null ? 'EDITAR RECORRÊNCIA' : 'NOVA RECORRÊNCIA'">
<form class="fc-rec-form" @submit.prevent="submit">
<div class="fc-rec-form__row">
<input v-model="form.name" placeholder="Nome (ex: Netflix)" required class="fc-input fc-rec-form__name" />
<input v-model="amountRaw" placeholder="55,90" required class="fc-input fc-rec-form__amount" />
<div class="fc-label fc-rec-form__day-wrap">
Dia do mês
<input
type="number"
v-model.number="form.day_of_month"
min="1"
max="31"
class="fc-input fc-rec-form__day"
/>
</div>
<select v-model="form.category_id" class="fc-select fc-rec-form__cat">
<option :value="null">Sem categoria</option>
<option v-for="c in catStore.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<p v-if="formError" class="fc-rec-form__error fc-mono">{{ formError }}</p>
<div class="fc-rec-form__actions">
<button type="submit" class="fc-btn fc-btn--primary">{{ editId !== null ? 'SALVAR' : 'ADICIONAR' }}</button>
<button v-if="editId !== null" type="button" class="fc-btn fc-btn--ghost" @click="cancelEdit">CANCELAR</button>
</div>
</form>
<NeonPanel title="PERFIL">
<div class="fc-settings-profile">
<div class="fc-label fc-pixel">USUÁRIO</div>
<div class="fc-settings-profile__name fc-mono">{{ auth.profile?.name ?? '—' }}</div>
</div>
<button class="fc-btn fc-btn--danger fc-settings-logout" @click="logout">SAIR</button>
</NeonPanel>
<!-- List -->
<NeonPanel title="RECORRÊNCIAS">
<p v-if="store.loading" class="fc-rec-loading fc-mono">carregando...</p>
<ul v-else class="fc-rec-list">
<li
v-for="item in store.items"
:key="item.id"
class="fc-rec-item"
:class="{ 'fc-rec-item--editing': editId === item.id }"
>
<div class="fc-rec-item__info">
<span class="fc-body fc-rec-item__name">{{ item.name }}</span>
<span class="fc-mono fc-rec-item__meta">
Todo dia {{ item.day_of_month }} · {{ fmt(item.expected_amount) }} · {{ catName(item.category_id) }}
</span>
</div>
<div class="fc-rec-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="startEdit(item.id)">EDITAR</button>
<button class="fc-btn fc-btn--sm fc-btn--danger" @click="remove(item.id, item.name)"></button>
</div>
</li>
<li v-if="store.items.length === 0" class="fc-rec-empty fc-mono"> nenhuma recorrência cadastrada </li>
</ul>
<NeonPanel title="TROCAR SENHA">
<form class="fc-settings-pw" @submit.prevent="changePassword">
<input
v-model="currentPassword"
type="password"
placeholder="Senha atual"
class="fc-input"
autocomplete="current-password"
:disabled="pwLoading"
required
/>
<input
v-model="newPassword"
type="password"
placeholder="Nova senha (mín. 8 chars)"
class="fc-input"
autocomplete="new-password"
:disabled="pwLoading"
required
/>
<input
v-model="confirmPassword"
type="password"
placeholder="Confirmar nova senha"
class="fc-input"
autocomplete="new-password"
:disabled="pwLoading"
required
/>
<p v-if="pwError" class="fc-settings-pw__msg fc-settings-pw__msg--err fc-mono">{{ pwError }}</p>
<p v-if="pwSuccess" class="fc-settings-pw__msg fc-settings-pw__msg--ok fc-mono">Senha alterada!</p>
<button type="submit" class="fc-btn fc-btn--primary" :disabled="pwLoading">
{{ pwLoading ? 'AGUARDE...' : 'SALVAR SENHA' }}
</button>
</form>
</NeonPanel>
</div>
</template>
<style scoped>
.fc-view {
max-width: 640px;
max-width: 480px;
margin: 0 auto;
padding: var(--fc-space-4);
display: flex;
@@ -140,78 +106,32 @@ function fmt(v: number) {
gap: var(--fc-space-4);
padding-bottom: 96px;
}
.fc-view__title {
font-size: 11px;
color: var(--fc-accent-2);
}
.fc-rec-form__row {
display: flex;
gap: var(--fc-space-2);
flex-wrap: wrap;
align-items: flex-end;
}
.fc-rec-form__name { flex: 1; min-width: 150px; }
.fc-rec-form__amount { width: 110px; }
.fc-rec-form__day-wrap { width: 90px; flex-shrink: 0; }
.fc-rec-form__day { width: 100%; }
.fc-rec-form__cat { width: 160px; }
.fc-rec-form__error {
color: var(--fc-red);
font-size: 11px;
margin-top: var(--fc-space-2);
}
.fc-rec-form__actions {
display: flex;
gap: var(--fc-space-2);
margin-top: var(--fc-space-3);
}
/* List */
.fc-rec-loading, .fc-rec-empty {
font-size: 11px;
color: var(--fc-text-dim);
text-align: center;
padding: var(--fc-space-4) 0;
}
.fc-rec-list {
list-style: none;
padding: 0;
margin: 0;
.fc-settings-profile {
display: flex;
flex-direction: column;
gap: var(--fc-space-2);
gap: 6px;
margin-bottom: var(--fc-space-4);
}
.fc-rec-item {
.fc-settings-profile__name {
font-size: 18px;
color: var(--fc-text);
}
.fc-settings-logout {
width: 100%;
}
.fc-settings-pw {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
gap: var(--fc-space-3);
padding: 12px var(--fc-space-3);
border: 1px solid var(--fc-panel-edge);
border-radius: var(--fc-radius);
flex-wrap: wrap;
transition: border-color .15s;
}
.fc-rec-item--editing {
border-color: var(--fc-accent-3);
box-shadow: 0 0 8px rgba(168,85,247,.3);
.fc-settings-pw__msg {
font-size: 11px;
margin: 0;
}
.fc-rec-item__info {
display: flex;
flex-direction: column;
gap: 4px;
}
.fc-rec-item__name { font-size: 14px; font-weight: 500; }
.fc-rec-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.fc-rec-item__actions { display: flex; gap: var(--fc-space-1); }
.fc-settings-pw__msg--err { color: var(--fc-red); }
.fc-settings-pw__msg--ok { color: var(--fc-green); }
</style>