From 85602dbf95e3a44742b444c15e7c0700a8479053 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 10 Jul 2026 14:08:47 +0200 Subject: [PATCH] Added "create list" handler, service + repository --- cmd/api-server/main.go | 11 +++--- internal/api/handler/list.go | 35 +++++++++++++++++++ internal/api/request/.gitkeep | 0 internal/api/request/list.go | 27 +++++++++++++++ internal/api/router/router.go | 14 +++++++- internal/domain/.gitkeep | 0 internal/domain/list.go | 37 ++++++++++++++++++++ internal/repository/list.go | 64 +++++++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 internal/api/handler/list.go delete mode 100644 internal/api/request/.gitkeep create mode 100644 internal/api/request/list.go delete mode 100644 internal/domain/.gitkeep create mode 100644 internal/domain/list.go create mode 100644 internal/repository/list.go diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index c868881..e654bee 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "fmt" "log/slog" @@ -68,7 +69,7 @@ func run(serviceName string, serviceVersion string) error { } }() - srv := makeServer(cfg) + srv := makeServer(db, cfg.Port) go func() { slog.Info("Starting http server", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -105,12 +106,14 @@ func setupLogging() { slog.SetDefault(logger) } -func makeServer(cfg *config.Config) *http.Server { - routerConfig := router.Config{} +func makeServer(db *sql.DB, port int) *http.Server { + routerConfig := router.Config{ + Database: db, + } mux := router.NewMux(routerConfig) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.Port), + Addr: fmt.Sprintf(":%d", port), Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go new file mode 100644 index 0000000..513c5ce --- /dev/null +++ b/internal/api/handler/list.go @@ -0,0 +1,35 @@ +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 ListHandler struct { + ListService *domain.ListService +} + +func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + payload, err := request.DecodeCreateList(r) + if err != nil { + slog.ErrorContext(ctx, "Failed to decode create list payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "Failed to decode request body") + return + } + + list, err := h.ListService.Create(ctx, payload.Name, payload.UserIDs) + if err != nil { + slog.ErrorContext(ctx, "Failed to create list", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "Failed to create list") + return + } + + slog.InfoContext(ctx, "Created list successfully", slog.Any("list_id", list.ID)) + response.JSON(ctx, w, http.StatusCreated, list) +} diff --git a/internal/api/request/.gitkeep b/internal/api/request/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/api/request/list.go b/internal/api/request/list.go new file mode 100644 index 0000000..be4f909 --- /dev/null +++ b/internal/api/request/list.go @@ -0,0 +1,27 @@ +package request + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type CreateListPayload struct { + Name string `json:"name"` + UserIDs []string `json:"user_ids"` +} + +func DecodeCreateList(r *http.Request) (CreateListPayload, error) { + var payload CreateListPayload + + r.Body = http.MaxBytesReader(nil, r.Body, 1024*64) + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("error decoding create list payload: %w", err) + } + + return payload, nil +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index f4586b5..8ca038f 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -1,20 +1,32 @@ package router import ( + "database/sql" "net/http" "github.com/robindittmar/dttmr-api/internal/api/handler" "github.com/robindittmar/dttmr-api/internal/api/middleware" + "github.com/robindittmar/dttmr-api/internal/domain" + "github.com/robindittmar/dttmr-api/internal/repository" ) -type Config struct{} +type Config struct { + Database *sql.DB +} func NewMux(cfg Config) http.Handler { + listRepo := repository.NewListRepo(cfg.Database) + listService := domain.NewListService(listRepo) + + listHandler := handler.ListHandler{ListService: listService} + mux := http.NewServeMux() mux.HandleFunc("/", handler.DefaultHandler) mux.HandleFunc("GET /health", handler.HealthHandler) + mux.HandleFunc("POST /lists", listHandler.CreateList) + var httpHandler http.Handler = mux httpHandler = middleware.WithTelemetry(httpHandler) diff --git a/internal/domain/.gitkeep b/internal/domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/internal/domain/list.go b/internal/domain/list.go new file mode 100644 index 0000000..283ff0d --- /dev/null +++ b/internal/domain/list.go @@ -0,0 +1,37 @@ +package domain + +import ( + "context" + "errors" + "time" +) + +type List struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +type ListRepository interface { + CreateList(ctx context.Context, name string, userIDs []string) (*List, error) +} + +type ListService struct { + repo ListRepository +} + +func NewListService(repo ListRepository) *ListService { + return &ListService{repo: repo} +} + +func (s *ListService) Create(ctx context.Context, name string, userIDs []string) (*List, error) { + if len(userIDs) == 0 { + return nil, errors.New("users must have at least one associated user") + } + + if name == "" { + return nil, errors.New("list name must not be empty") + } + + return s.repo.CreateList(ctx, name, userIDs) +} diff --git a/internal/repository/list.go b/internal/repository/list.go new file mode 100644 index 0000000..3671bc1 --- /dev/null +++ b/internal/repository/list.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type ListRepo struct { + db *sql.DB +} + +func NewListRepo(db *sql.DB) *ListRepo { + return &ListRepo{db: db} +} + +func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string) (*domain.List, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer func() { + err := tx.Rollback() + if err != nil { + slog.Error("Failed to rollback transaction", slog.Any("error", err)) + } + }() + + list := &domain.List{Name: name} + + err = tx.QueryRowContext(ctx, + "INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at", + name, + ).Scan(&list.ID, &list.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to insert list: %w", err) + } + + stmt, err := tx.PrepareContext(ctx, "INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)") + if err != nil { + return nil, fmt.Errorf("failed to prepare user/list association statement: %w", err) + } + defer func() { + err := stmt.Close() + if err != nil { + + } + }() + + for _, userID := range userIDs { + if _, err = stmt.ExecContext(ctx, list.ID, userID); err != nil { + return nil, fmt.Errorf("failed to insert user/list association: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit transaction: %w", err) + } + + return list, err +}