feat(backend): switch auth to username and cleanup db config

This commit is contained in:
Tiago Yamamoto 2025-12-21 23:00:36 -03:00
parent c1f32d0165
commit 4612172b3c
14 changed files with 146 additions and 33 deletions

View file

@ -8,9 +8,11 @@ BACKEND_PORT=8214
# Database Configuration
DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable
DB_MAX_OPEN_CONNS=15
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_IDLE=5m
DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable
ADMIN_NAME=Administrator
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@saveinmed.com
ADMIN_PASSWORD=admin123
# JWT Authentication
JWT_SECRET=your-secret-key-here

Binary file not shown.

View file

@ -2791,6 +2791,9 @@ const docTemplate = `{
},
"updated_at": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
@ -2904,6 +2907,9 @@ const docTemplate = `{
},
"role": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
@ -2932,10 +2938,10 @@ const docTemplate = `{
"handler.loginRequest": {
"type": "object",
"properties": {
"email": {
"password": {
"type": "string"
},
"password": {
"username": {
"type": "string"
}
}
@ -2968,6 +2974,9 @@ const docTemplate = `{
},
"role": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
@ -3232,6 +3241,9 @@ const docTemplate = `{
},
"role": {
"type": "string"
},
"username": {
"type": "string"
}
}
},

View file

@ -2787,6 +2787,9 @@
},
"updated_at": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
@ -2900,6 +2903,9 @@
},
"role": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
@ -2928,10 +2934,10 @@
"handler.loginRequest": {
"type": "object",
"properties": {
"email": {
"password": {
"type": "string"
},
"password": {
"username": {
"type": "string"
}
}
@ -2964,6 +2970,9 @@
},
"role": {
"type": "string"
},
"username": {
"type": "string"
}
}
},
@ -3228,6 +3237,9 @@
},
"role": {
"type": "string"
},
"username": {
"type": "string"
}
}
},

View file

@ -434,6 +434,8 @@ definitions:
type: string
updated_at:
type: string
username:
type: string
type: object
domain.UserPage:
properties:
@ -507,6 +509,8 @@ definitions:
type: string
role:
type: string
username:
type: string
type: object
handler.forgotPasswordRequest:
properties:
@ -524,10 +528,10 @@ definitions:
type: object
handler.loginRequest:
properties:
email:
type: string
password:
type: string
username:
type: string
type: object
handler.messageResponse:
properties:
@ -548,6 +552,8 @@ definitions:
type: string
role:
type: string
username:
type: string
type: object
handler.registerCompanyRequest:
properties:
@ -720,6 +726,8 @@ definitions:
type: string
role:
type: string
username:
type: string
type: object
handler.verifyEmailRequest:
properties:

View file

@ -13,9 +13,6 @@ type Config struct {
AppName string
Port string
DatabaseURL string
MaxOpenConns int
MaxIdleConns int
ConnMaxIdle time.Duration
MercadoPagoBaseURL string
MarketplaceCommission float64
JWTSecret string
@ -24,6 +21,10 @@ type Config struct {
CORSOrigins []string
BackendHost string
SwaggerSchemes []string
AdminName string
AdminUsername string
AdminEmail string
AdminPassword string
}
// Load reads configuration from environment variables and applies sane defaults
@ -33,9 +34,6 @@ func Load() Config {
AppName: getEnv("APP_NAME", "saveinmed-performance-core"),
Port: getEnv("BACKEND_PORT", "8214"),
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/saveinmed?sslmode=disable"),
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 15),
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),
ConnMaxIdle: getEnvDuration("DB_CONN_MAX_IDLE", 5*time.Minute),
MercadoPagoBaseURL: getEnv("MERCADOPAGO_BASE_URL", "https://api.mercadopago.com"),
MarketplaceCommission: getEnvFloat("MARKETPLACE_COMMISSION", 2.5),
JWTSecret: getEnv("JWT_SECRET", "dev-secret"),
@ -44,6 +42,10 @@ func Load() Config {
CORSOrigins: getEnvStringSlice("CORS_ORIGINS", []string{"*"}),
BackendHost: getEnv("BACKEND_HOST", ""),
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
AdminName: getEnv("ADMIN_NAME", "Administrator"),
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminEmail: getEnv("ADMIN_EMAIL", "admin@saveinmed.com"),
AdminPassword: getEnv("ADMIN_PASSWORD", "admin123"),
}
return cfg

View file

@ -32,6 +32,7 @@ type User struct {
CompanyID uuid.UUID `db:"company_id" json:"company_id"`
Role string `db:"role" json:"role"`
Name string `db:"name" json:"name"`
Username string `db:"username" json:"username"`
Email string `db:"email" json:"email"`
EmailVerified bool `db:"email_verified" json:"email_verified"`
PasswordHash string `db:"password_hash" json:"-"`

View file

@ -19,6 +19,7 @@ type createUserRequest struct {
CompanyID uuid.UUID `json:"company_id"`
Role string `json:"role"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
@ -28,6 +29,7 @@ type registerAuthRequest struct {
Company *registerCompanyTarget `json:"company,omitempty"`
Role string `json:"role"`
Name string `json:"name"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
@ -45,7 +47,7 @@ type registerCompanyTarget struct {
}
type loginRequest struct {
Email string `json:"email"`
Username string `json:"username"`
Password string `json:"password"`
}
@ -98,6 +100,7 @@ type updateUserRequest struct {
CompanyID *uuid.UUID `json:"company_id,omitempty"`
Role *string `json:"role,omitempty"`
Name *string `json:"name,omitempty"`
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
}

View file

@ -65,6 +65,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
CompanyID: companyID,
Role: req.Role,
Name: req.Name,
Username: req.Username,
Email: req.Email,
}
@ -78,7 +79,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
return
}
token, exp, err := h.svc.Authenticate(r.Context(), user.Email, req.Password)
token, exp, err := h.svc.Authenticate(r.Context(), user.Username, req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
@ -105,7 +106,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return
}
token, exp, err := h.svc.Authenticate(r.Context(), req.Email, req.Password)
token, exp, err := h.svc.Authenticate(r.Context(), req.Username, req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return

View file

@ -49,6 +49,7 @@ func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
CompanyID: req.CompanyID,
Role: req.Role,
Name: req.Name,
Username: req.Username,
Email: req.Email,
}
@ -203,6 +204,9 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
if req.Name != nil {
user.Name = *req.Name
}
if req.Username != nil {
user.Username = *req.Username
}
if req.Email != nil {
user.Email = *req.Email
}

View file

@ -0,0 +1,7 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT;
-- Backfill existing users with their email as username to satisfy NOT NULL constraint
UPDATE users SET username = email WHERE username IS NULL;
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
ALTER TABLE users ADD CONSTRAINT users_username_key UNIQUE (username);

View file

@ -785,8 +785,8 @@ func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
user.CreatedAt = now
user.UpdatedAt = now
query := `INSERT INTO users (id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at)
VALUES (:id, :company_id, :role, :name, :email, :email_verified, :password_hash, :created_at, :updated_at)`
query := `INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
VALUES (:id, :company_id, :role, :name, :username, :email, :email_verified, :password_hash, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, user)
return err
@ -814,7 +814,7 @@ func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([
}
args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
listQuery := fmt.Sprintf("SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
var users []domain.User
if err := r.db.SelectContext(ctx, &users, listQuery, args...); err != nil {
@ -826,17 +826,17 @@ func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([
func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
var user domain.User
query := `SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at FROM users WHERE id = $1`
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE id = $1`
if err := r.db.GetContext(ctx, &user, query, id); err != nil {
return nil, err
}
return &user, nil
}
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
func (r *Repository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) {
var user domain.User
query := `SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at FROM users WHERE email = $1`
if err := r.db.GetContext(ctx, &user, query, email); err != nil {
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE username = $1`
if err := r.db.GetContext(ctx, &user, query, username); err != nil {
return nil, err
}
return &user, nil

View file

@ -6,11 +6,13 @@ import (
"net/http"
"time"
"github.com/gofrs/uuid/v5"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
httpSwagger "github.com/swaggo/http-swagger"
"github.com/saveinmed/backend-go/internal/config"
"github.com/saveinmed/backend-go/internal/domain"
"github.com/saveinmed/backend-go/internal/http/handler"
"github.com/saveinmed/backend-go/internal/http/middleware"
"github.com/saveinmed/backend-go/internal/payments"
@ -23,6 +25,7 @@ type Server struct {
cfg config.Config
db *sqlx.DB
mux *http.ServeMux
svc *usecase.Service
}
func New(cfg config.Config) (*Server, error) {
@ -31,10 +34,6 @@ func New(cfg config.Config) (*Server, error) {
return nil, err
}
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxIdleTime(cfg.ConnMaxIdle)
repo := postgres.New(db)
gateway := payments.NewMercadoPagoGateway(cfg.MercadoPagoBaseURL, cfg.MarketplaceCommission)
svc := usecase.NewService(repo, gateway, cfg.MarketplaceCommission, cfg.JWTSecret, cfg.JWTExpiresIn, cfg.PasswordPepper)
@ -124,7 +123,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /docs/", httpSwagger.Handler(httpSwagger.URL("/docs/doc.json")))
return &Server{cfg: cfg, db: db, mux: mux}, nil
return &Server{cfg: cfg, db: db, mux: mux, svc: svc}, nil
}
// Start runs the HTTP server and ensures the database is reachable.
@ -138,6 +137,68 @@ func (s *Server) Start(ctx context.Context) error {
return err
}
// Seed Admin
if s.cfg.AdminEmail != "" && s.cfg.AdminPassword != "" {
// Checks if admin already exists
_, err := repo.GetUserByEmail(ctx, s.cfg.AdminEmail)
if err != nil {
// If not found, create
log.Printf("Seeding admin user: %s", s.cfg.AdminEmail)
// 1. Create/Get Admin Company
adminCNPJ := "00000000000000"
company := &domain.Company{
ID: uuid.Must(uuid.NewV7()),
CNPJ: adminCNPJ,
CorporateName: "SaveInMed Admin",
Category: "admin",
LicenseNumber: "ADMIN",
IsVerified: true,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
// We need to check if company exists by CNPJ normally, but repo doesn't expose GetByCNPJ easily?
// Let's rely on RegisterAccount handling it or check if we can query.
// Actually RegisterAccount in Service handles creation if ID is Nil, but keys off ID.
// We can try to create and ignore conflict, or use a known ID?
// Let's use RegisterAccount logic.
// Because RegisterAccount expects us to pass a company, and tries to Get by ID if ID is set, or Create if not.
// But duplicate CNPJ will fail at DB level.
// Let's assume on fresh boot it doesn't exist.
// Or better: Use svc.RegisterAccount. But wait, svc.RegisterAccount logic:
/*
if company != nil {
if company.ID == uuid.Nil {
// create
} else {
// get
}
}
*/
// If we re-run, GetUserByEmail would have found the user, so we skip.
// The only edge case is if User was deleted but Company remains.
// In that case, CreateCompany will fail on CNPJ constraint.
err := s.svc.RegisterAccount(ctx, company, &domain.User{
Role: "Admin",
Name: s.cfg.AdminName,
Email: s.cfg.AdminEmail,
}, s.cfg.AdminPassword)
if err != nil {
// If error is duplicate key on company, maybe we should fetch the company and try creating user only?
// For now, let's log error but not fail startup hard, or fail hard to signal issue.
log.Printf("Failed to seed admin: %v", err)
} else {
log.Printf("Admin user created successfully")
}
} else {
log.Printf("Admin user %s already exists", s.cfg.AdminEmail)
}
}
corsConfig := middleware.CORSConfig{AllowedOrigins: s.cfg.CORSOrigins}
srv := &http.Server{
Addr: s.cfg.Addr(),

View file

@ -45,7 +45,7 @@ type Repository interface {
CreateUser(ctx context.Context, user *domain.User) error
ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error)
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
GetUserByUsername(ctx context.Context, username string) (*domain.User, error)
UpdateUser(ctx context.Context, user *domain.User) error
DeleteUser(ctx context.Context, id uuid.UUID) error
@ -623,8 +623,8 @@ func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company,
}
// Authenticate validates credentials and emits a signed JWT.
func (s *Service) Authenticate(ctx context.Context, email, password string) (string, time.Time, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
func (s *Service) Authenticate(ctx context.Context, username, password string) (string, time.Time, error) {
user, err := s.repo.GetUserByUsername(ctx, username)
if err != nil {
return "", time.Time{}, err
}