diff --git a/backend/go.mod b/backend/go.mod index 5674bb3..0f942c9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -9,6 +9,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.37.0 ) require ( @@ -26,7 +27,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect - golang.org/x/crypto v0.37.0 // indirect golang.org/x/mod v0.21.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.13.0 // indirect diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index ad48a5a..2a95f6d 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -17,6 +17,33 @@ type Company struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +// User represents an authenticated actor inside a company. +type User struct { + ID uuid.UUID `db:"id" json:"id"` + CompanyID uuid.UUID `db:"company_id" json:"company_id"` + Role string `db:"role" json:"role"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` + PasswordHash string `db:"password_hash" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// UserFilter captures listing constraints. +type UserFilter struct { + CompanyID *uuid.UUID + Limit int + Offset int +} + +// UserPage wraps paginated results. +type UserPage struct { + Users []User `json:"users"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + // Product represents a medicine SKU with batch tracking. type Product struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/backend/internal/http/handler/handler.go b/backend/internal/http/handler/handler.go index aaa8c7d..6b056dd 100644 --- a/backend/internal/http/handler/handler.go +++ b/backend/internal/http/handler/handler.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strconv" "strings" "time" @@ -240,6 +241,265 @@ func (h *Handler) CreatePaymentPreference(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusCreated, pref) } +// CreateUser handles the creation of platform users. +func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + var req createUserRequest + if err := decodeJSON(r.Context(), r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + if strings.EqualFold(requester.Role, "Seller") { + if requester.CompanyID == nil { + writeError(w, http.StatusBadRequest, errors.New("seller must include X-Company-ID header")) + return + } + if req.CompanyID != *requester.CompanyID { + writeError(w, http.StatusForbidden, errors.New("seller can only manage their own company users")) + return + } + } + + user := &domain.User{ + CompanyID: req.CompanyID, + Role: req.Role, + Name: req.Name, + Email: req.Email, + } + + if err := h.svc.CreateUser(r.Context(), user, req.Password); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusCreated, user) +} + +// ListUsers supports pagination and optional company filter. +func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + page, pageSize := parsePagination(r) + + var companyFilter *uuid.UUID + if cid := r.URL.Query().Get("company_id"); cid != "" { + id, err := uuid.FromString(cid) + if err != nil { + writeError(w, http.StatusBadRequest, errors.New("invalid company_id")) + return + } + companyFilter = &id + } + + if strings.EqualFold(requester.Role, "Seller") { + if requester.CompanyID == nil { + writeError(w, http.StatusBadRequest, errors.New("seller must include X-Company-ID header")) + return + } + companyFilter = requester.CompanyID + } + + pageResult, err := h.svc.ListUsers(r.Context(), domain.UserFilter{CompanyID: companyFilter}, page, pageSize) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, pageResult) +} + +// GetUser returns a single user by ID. +func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) { + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + id, err := parseUUIDFromPath(r.URL.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + user, err := h.svc.GetUser(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + if strings.EqualFold(requester.Role, "Seller") { + if requester.CompanyID == nil || user.CompanyID != *requester.CompanyID { + writeError(w, http.StatusForbidden, errors.New("seller can only view users from their company")) + return + } + } + + writeJSON(w, http.StatusOK, user) +} + +// UpdateUser updates profile fields or password. +func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + id, err := parseUUIDFromPath(r.URL.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + var req updateUserRequest + if err := decodeJSON(r.Context(), r, &req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + user, err := h.svc.GetUser(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + if strings.EqualFold(requester.Role, "Seller") { + if requester.CompanyID == nil || user.CompanyID != *requester.CompanyID { + writeError(w, http.StatusForbidden, errors.New("seller can only update their company users")) + return + } + } + + if req.CompanyID != nil { + user.CompanyID = *req.CompanyID + } + if req.Role != nil { + user.Role = *req.Role + } + if req.Name != nil { + user.Name = *req.Name + } + if req.Email != nil { + user.Email = *req.Email + } + + newPassword := "" + if req.Password != nil { + newPassword = *req.Password + } + + if err := h.svc.UpdateUser(r.Context(), user, newPassword); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, user) +} + +// DeleteUser removes a user by ID. +func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { + requester, err := getRequester(r) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + id, err := parseUUIDFromPath(r.URL.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + user, err := h.svc.GetUser(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + if strings.EqualFold(requester.Role, "Seller") { + if requester.CompanyID == nil || user.CompanyID != *requester.CompanyID { + writeError(w, http.StatusForbidden, errors.New("seller can only delete their company users")) + return + } + } + + if err := h.svc.DeleteUser(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type createUserRequest struct { + CompanyID uuid.UUID `json:"company_id"` + Role string `json:"role"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password"` +} + +type updateUserRequest struct { + CompanyID *uuid.UUID `json:"company_id,omitempty"` + Role *string `json:"role,omitempty"` + Name *string `json:"name,omitempty"` + Email *string `json:"email,omitempty"` + Password *string `json:"password,omitempty"` +} + +type requester struct { + Role string + CompanyID *uuid.UUID +} + +func parsePagination(r *http.Request) (int, int) { + page := 1 + pageSize := 20 + + if v := r.URL.Query().Get("page"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 { + page = p + } + } + + if v := r.URL.Query().Get("page_size"); v != "" { + if ps, err := strconv.Atoi(v); err == nil && ps > 0 { + pageSize = ps + } + } + + return page, pageSize +} + +func getRequester(r *http.Request) (requester, error) { + role := r.Header.Get("X-User-Role") + if role == "" { + role = "Admin" + } + + var companyID *uuid.UUID + if cid := r.Header.Get("X-Company-ID"); cid != "" { + id, err := uuid.FromString(cid) + if err != nil { + return requester{}, errors.New("invalid X-Company-ID header") + } + companyID = &id + } + + return requester{Role: role, CompanyID: companyID}, nil +} + type registerCompanyRequest struct { Role string `json:"role"` CNPJ string `json:"cnpj"` diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 71b0242..b798406 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/gofrs/uuid/v5" @@ -127,6 +128,95 @@ func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status return nil } +func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error { + now := time.Now().UTC() + user.CreatedAt = now + user.UpdatedAt = now + + query := `INSERT INTO users (id, company_id, role, name, email, password_hash, created_at, updated_at) +VALUES (:id, :company_id, :role, :name, :email, :password_hash, :created_at, :updated_at)` + + _, err := r.db.NamedExecContext(ctx, query, user) + return err +} + +func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) { + baseQuery := `FROM users` + var args []any + var clauses []string + + if filter.CompanyID != nil { + clauses = append(clauses, fmt.Sprintf("company_id = $%d", len(args)+1)) + args = append(args, *filter.CompanyID) + } + + where := "" + if len(clauses) > 0 { + where = " WHERE " + strings.Join(clauses, " AND ") + } + + countQuery := "SELECT count(*) " + baseQuery + where + var total int64 + if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { + return nil, 0, err + } + + args = append(args, filter.Limit, filter.Offset) + listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, 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 { + return nil, 0, err + } + + return users, total, nil +} + +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, 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) UpdateUser(ctx context.Context, user *domain.User) error { + user.UpdatedAt = time.Now().UTC() + + query := `UPDATE users +SET company_id = :company_id, role = :role, name = :name, email = :email, password_hash = :password_hash, updated_at = :updated_at +WHERE id = :id` + + res, err := r.db.NamedExecContext(ctx, query, user) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return errors.New("user not found") + } + return nil +} + +func (r *Repository) DeleteUser(ctx context.Context, id uuid.UUID) error { + res, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + return errors.New("user not found") + } + return nil +} + // InitSchema applies a minimal schema for development environments. func (r *Repository) InitSchema(ctx context.Context) error { schema := ` @@ -140,6 +230,17 @@ CREATE TABLE IF NOT EXISTS companies ( updated_at TIMESTAMPTZ NOT NULL ); +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + company_id UUID NOT NULL REFERENCES companies(id), + role TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + CREATE TABLE IF NOT EXISTS products ( id UUID PRIMARY KEY, seller_id UUID NOT NULL REFERENCES companies(id), diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index ce0679c..9f8137b 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -70,6 +70,12 @@ func New(cfg config.Config) (*Server, error) { mux.Handle("PATCH /api/orders/", chain(http.HandlerFunc(h.UpdateOrderStatus), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/orders/", chain(http.HandlerFunc(h.CreatePaymentPreference), middleware.Logger, middleware.Gzip)) + mux.Handle("POST /api/v1/users", chain(http.HandlerFunc(h.CreateUser), middleware.Logger, middleware.Gzip)) + mux.Handle("GET /api/v1/users", chain(http.HandlerFunc(h.ListUsers), middleware.Logger, middleware.Gzip)) + mux.Handle("GET /api/v1/users/", chain(http.HandlerFunc(h.GetUser), middleware.Logger, middleware.Gzip)) + mux.Handle("PUT /api/v1/users/", chain(http.HandlerFunc(h.UpdateUser), middleware.Logger, middleware.Gzip)) + mux.Handle("DELETE /api/v1/users/", chain(http.HandlerFunc(h.DeleteUser), middleware.Logger, middleware.Gzip)) + mux.Handle("GET /swagger/", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json"))) return &Server{cfg: cfg, db: db, mux: mux}, nil diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index 2a3d0e0..1836dd0 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -3,6 +3,8 @@ package usecase import ( "context" + "golang.org/x/crypto/bcrypt" + "github.com/gofrs/uuid/v5" "github.com/saveinmed/backend-go/internal/domain" @@ -19,6 +21,12 @@ type Repository interface { CreateOrder(ctx context.Context, order *domain.Order) error GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error + + 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) + UpdateUser(ctx context.Context, user *domain.User) error + DeleteUser(ctx context.Context, id uuid.UUID) error } // PaymentGateway abstracts Mercado Pago integration. @@ -75,3 +83,54 @@ func (s *Service) CreatePaymentPreference(ctx context.Context, id uuid.UUID) (*d } return s.pay.CreatePreference(ctx, order) } + +func (s *Service) CreateUser(ctx context.Context, user *domain.User, password string) error { + hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + user.ID = uuid.Must(uuid.NewV7()) + user.PasswordHash = string(hashed) + + return s.repo.CreateUser(ctx, user) +} + +func (s *Service) ListUsers(ctx context.Context, filter domain.UserFilter, page, pageSize int) (*domain.UserPage, error) { + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + filter.Limit = pageSize + filter.Offset = (page - 1) * pageSize + + users, total, err := s.repo.ListUsers(ctx, filter) + if err != nil { + return nil, err + } + + return &domain.UserPage{Users: users, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) { + return s.repo.GetUser(ctx, id) +} + +func (s *Service) UpdateUser(ctx context.Context, user *domain.User, newPassword string) error { + if newPassword != "" { + hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + user.PasswordHash = string(hashed) + } + + return s.repo.UpdateUser(ctx, user) +} + +func (s *Service) DeleteUser(ctx context.Context, id uuid.UUID) error { + return s.repo.DeleteUser(ctx, id) +}