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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
package model
|
||||
|
||||
type Profile struct {
|
||||
ID int
|
||||
Name string
|
||||
PasswordHash string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: '/configuracoes',
|
||||
redirect: '/categorias',
|
||||
name: 'settings',
|
||||
component: () => import('../views/SettingsView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user