diff --git a/go.mod b/go.mod index 65d2c29..7b1edd5 100644 --- a/go.mod +++ b/go.mod @@ -13,30 +13,31 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 + golang.org/x/crypto v0.54.0 ) require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // 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 - github.com/lib/pq v1.10.9 // indirect + github.com/lib/pq v1.12.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect - google.golang.org/grpc v1.82.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260720171339-e059f2f05d78 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260720171339-e059f2f05d78 // indirect + google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 08630fa..987e8f3 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -13,6 +13,10 @@ type ListHandler struct { ListService *domain.ListService } +func NewListHandler(listService *domain.ListService) *ListHandler { + return &ListHandler{ListService: listService} +} + func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go new file mode 100644 index 0000000..e80b7d8 --- /dev/null +++ b/internal/api/handler/user.go @@ -0,0 +1,39 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/robindittmar/dttmr-api/internal/api/request" + "github.com/robindittmar/dttmr-api/internal/api/response" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type UserHandler struct { + UserService *domain.UserService +} + +func NewUserHandler(userService *domain.UserService) *UserHandler { + return &UserHandler{UserService: userService} +} + +func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + payload, err := request.DecodeCreateUser(r) + if err != nil { + slog.ErrorContext(ctx, "failed to decode create user payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body") + return + } + + user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password) + if err != nil { + slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to create user") + return + } + + slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID)) + response.JSON(ctx, w, http.StatusCreated, user) +} diff --git a/internal/api/request/user.go b/internal/api/request/user.go new file mode 100644 index 0000000..570a3ac --- /dev/null +++ b/internal/api/request/user.go @@ -0,0 +1,26 @@ +package request + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type CreateUserPayload struct { + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password"` +} + +func DecodeCreateUser(r *http.Request) (CreateUserPayload, error) { + var payload CreateUserPayload + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("error decoding create user payload: %w", err) + } + + return payload, nil +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index a6cf4b7..43d3540 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -15,16 +15,21 @@ type Config struct { } func NewMux(cfg Config) http.Handler { + userRepo := repository.NewUserRepo(cfg.Database) + userService := domain.NewUserService(userRepo) + userHandler := handler.NewUserHandler(userService) + listRepo := repository.NewListRepo(cfg.Database) listService := domain.NewListService(listRepo) - - listHandler := handler.ListHandler{ListService: listService} + listHandler := handler.NewListHandler(listService) mux := http.NewServeMux() mux.HandleFunc("/", handler.DefaultHandler) mux.HandleFunc("GET /health", handler.HealthHandler) + mux.HandleFunc("POST /users", userHandler.CreateUser) + mux.HandleFunc("POST /lists", listHandler.CreateList) var httpHandler http.Handler = mux diff --git a/internal/domain/list.go b/internal/domain/list.go index b711652..5989e39 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -21,8 +21,8 @@ type ListService struct { repo ListRepository } -func NewListService(repo ListRepository) *ListService { - return &ListService{repo: repo} +func NewListService(r ListRepository) *ListService { + return &ListService{repo: r} } func (s *ListService) Create(ctx context.Context, name string, userIDs []string) (*List, error) { diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..a4675b2 --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,49 @@ +package domain + +import ( + "context" + "errors" + "time" + + "golang.org/x/crypto/bcrypt" +) + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +type UserRepository interface { + CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error) +} + +type UserService struct { + repo UserRepository +} + +func NewUserService(r UserRepository) *UserService { + return &UserService{repo: r} +} + +func (s *UserService) CreateUser(ctx context.Context, email string, name string, password string) (*User, error) { + if len(email) == 0 { + return nil, errors.New("email is required") + } + + if len(name) == 0 { + return nil, errors.New("name is required") + } + + if len(password) == 0 { + return nil, errors.New("password is required") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + return s.repo.CreateUser(ctx, email, name, string(hash)) +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..f956848 --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type UserRepo struct { + db *sql.DB +} + +func NewUserRepo(db *sql.DB) *UserRepo { + return &UserRepo{db: db} +} + +func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, passwordHash string) (*domain.User, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + user := &domain.User{Email: email, Name: name} + + err = tx.QueryRowContext(ctx, + "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at", + email, name, passwordHash, + ).Scan(&user.ID, &user.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to insert user: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + + return user, err +}