diff --git a/backend/.env.example b/backend/.env.example index 6aa94f2..b8a0ced 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/api b/backend/api index 2fe6cc8..8df035d 100755 Binary files a/backend/api and b/backend/api differ diff --git a/backend/docs/docs.go b/backend/docs/docs.go index f48efe5..07077a9 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -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" } } }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index d220144..484f62d 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -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" } } }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 8c40051..6566ff5 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -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: diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index befc415..14e658a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 855c4e0..f5444e9 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -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:"-"` diff --git a/backend/internal/http/handler/dto.go b/backend/internal/http/handler/dto.go index 278704b..854b3fd 100644 --- a/backend/internal/http/handler/dto.go +++ b/backend/internal/http/handler/dto.go @@ -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"` } diff --git a/backend/internal/http/handler/handler.go b/backend/internal/http/handler/handler.go index 523b1ff..da1d976 100644 --- a/backend/internal/http/handler/handler.go +++ b/backend/internal/http/handler/handler.go @@ -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 diff --git a/backend/internal/http/handler/user_handler.go b/backend/internal/http/handler/user_handler.go index c9d4dd1..ca0d4dc 100644 --- a/backend/internal/http/handler/user_handler.go +++ b/backend/internal/http/handler/user_handler.go @@ -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 } diff --git a/backend/internal/repository/postgres/migrations/0004_add_username_to_users.sql b/backend/internal/repository/postgres/migrations/0004_add_username_to_users.sql new file mode 100644 index 0000000..481576f --- /dev/null +++ b/backend/internal/repository/postgres/migrations/0004_add_username_to_users.sql @@ -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); diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 75d2e22..804a6e4 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -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 diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 3748cc5..b91962d 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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(), diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 7930506..2fd6ac8 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -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 }