Implement user CRUD endpoints

This commit is contained in:
Tiago Yamamoto 2025-12-18 11:42:23 -03:00
parent ee6a0a5375
commit b461ff5201
6 changed files with 454 additions and 1 deletions

View file

@ -9,6 +9,7 @@ require (
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6 github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.37.0
) )
require ( require (
@ -26,7 +27,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // 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/mod v0.21.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.13.0 // indirect golang.org/x/sync v0.13.0 // indirect

View file

@ -17,6 +17,33 @@ type Company struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 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. // Product represents a medicine SKU with batch tracking.
type Product struct { type Product struct {
ID uuid.UUID `db:"id" json:"id"` ID uuid.UUID `db:"id" json:"id"`

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@ -240,6 +241,265 @@ func (h *Handler) CreatePaymentPreference(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusCreated, pref) 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 { type registerCompanyRequest struct {
Role string `json:"role"` Role string `json:"role"`
CNPJ string `json:"cnpj"` CNPJ string `json:"cnpj"`

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/gofrs/uuid/v5" "github.com/gofrs/uuid/v5"
@ -127,6 +128,95 @@ func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status
return nil 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. // InitSchema applies a minimal schema for development environments.
func (r *Repository) InitSchema(ctx context.Context) error { func (r *Repository) InitSchema(ctx context.Context) error {
schema := ` schema := `
@ -140,6 +230,17 @@ CREATE TABLE IF NOT EXISTS companies (
updated_at TIMESTAMPTZ NOT NULL 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 ( CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
seller_id UUID NOT NULL REFERENCES companies(id), seller_id UUID NOT NULL REFERENCES companies(id),

View file

@ -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("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/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"))) mux.Handle("GET /swagger/", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json")))
return &Server{cfg: cfg, db: db, mux: mux}, nil return &Server{cfg: cfg, db: db, mux: mux}, nil

View file

@ -3,6 +3,8 @@ package usecase
import ( import (
"context" "context"
"golang.org/x/crypto/bcrypt"
"github.com/gofrs/uuid/v5" "github.com/gofrs/uuid/v5"
"github.com/saveinmed/backend-go/internal/domain" "github.com/saveinmed/backend-go/internal/domain"
@ -19,6 +21,12 @@ type Repository interface {
CreateOrder(ctx context.Context, order *domain.Order) error CreateOrder(ctx context.Context, order *domain.Order) error
GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error)
UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) 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. // 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) 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)
}