Merge pull request 'Introduce database and first endpoint with service/repository' (#1) from dev into main
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
+22
-50
@@ -2,34 +2,29 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/router"
|
"github.com/robindittmar/dttmr-api/internal/api/router"
|
||||||
|
"github.com/robindittmar/dttmr-api/internal/config"
|
||||||
|
"github.com/robindittmar/dttmr-api/internal/database"
|
||||||
"github.com/robindittmar/dttmr-api/internal/telemetry"
|
"github.com/robindittmar/dttmr-api/internal/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Environment string
|
|
||||||
Port int
|
|
||||||
OTLPEndpoint string
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
serviceName := "dttmr-api"
|
serviceName := "dttmr-api"
|
||||||
serviceVersion := "0.1.0"
|
serviceVersion := "0.1.0"
|
||||||
|
|
||||||
if err := run(serviceName, serviceVersion); err != nil {
|
if err := run(serviceName, serviceVersion); err != nil {
|
||||||
slog.Error("Service crashed", slog.Any("error", err))
|
slog.Error("Service crashed")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +35,7 @@ func run(serviceName string, serviceVersion string) error {
|
|||||||
|
|
||||||
slog.Info("Starting service", slog.String("service", serviceName), slog.String("version", serviceVersion))
|
slog.Info("Starting service", slog.String("service", serviceName), slog.String("version", serviceVersion))
|
||||||
|
|
||||||
cfg := loadConfig()
|
cfg := config.Load()
|
||||||
|
|
||||||
telCfg := telemetry.Config{
|
telCfg := telemetry.Config{
|
||||||
ServiceName: serviceName,
|
ServiceName: serviceName,
|
||||||
@@ -62,7 +57,19 @@ func run(serviceName string, serviceVersion string) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
srv := makeServer(cfg)
|
db, err := database.New(context.Background(), cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to initialize database", slog.Any("error", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to close database connection", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
srv := makeServer(db, cfg.Port)
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("Starting http server", "addr", srv.Addr)
|
slog.Info("Starting http server", "addr", srv.Addr)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
@@ -99,49 +106,14 @@ func setupLogging() {
|
|||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() *Config {
|
func makeServer(db *sql.DB, port int) *http.Server {
|
||||||
envFlag := flag.String("env", "development", "environment to use")
|
routerConfig := router.Config{
|
||||||
portFlag := flag.Int("port", 8080, "port to listen on")
|
Database: db,
|
||||||
otlpEndpointFlag := flag.String("otlp-endpoint", "localhost:4317", "otlp endpoint")
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
Environment: *envFlag,
|
|
||||||
Port: *portFlag,
|
|
||||||
OTLPEndpoint: *otlpEndpointFlag,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment)
|
|
||||||
assignIntFromEnv("DTTMR_PORT", &cfg.Port)
|
|
||||||
assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint)
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func assignStringFromEnv(key string, target *string) {
|
|
||||||
if val, exists := os.LookupEnv(key); exists {
|
|
||||||
*target = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assignIntFromEnv(key string, target *int) {
|
|
||||||
if val, exists := os.LookupEnv(key); exists {
|
|
||||||
parsed, err := strconv.Atoi(val)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to parse environment variable", slog.String("var", key), slog.Any("error", err))
|
|
||||||
} else {
|
|
||||||
*target = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeServer(cfg *Config) *http.Server {
|
|
||||||
routerConfig := router.Config{}
|
|
||||||
mux := router.NewMux(routerConfig)
|
mux := router.NewMux(routerConfig)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
ReadTimeout: 5 * time.Second,
|
ReadTimeout: 5 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector:latest
|
||||||
|
command: ["--config=/etc/otel-collector-config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||||
|
ports:
|
||||||
|
- "4317:4317"
|
||||||
|
|
||||||
|
jaeger:
|
||||||
|
image: jaegertracing/all-in-one:latest
|
||||||
|
environment:
|
||||||
|
- COLLECTOR_OTLP_ENABLED=true
|
||||||
|
ports:
|
||||||
|
- "16686:16686"
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:latest
|
||||||
|
volumes:
|
||||||
|
- ./postgresql-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "15432:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=dttmr
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp/jaeger:
|
||||||
|
endpoint: jaeger:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
endpoint: 0.0.0.0:8889
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [otlp/jaeger]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [prometheus]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 5s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'otel-collector'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['otel-collector:8889']
|
||||||
@@ -3,6 +3,7 @@ module github.com/robindittmar/dttmr-api
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||||
go.opentelemetry.io/otel v1.44.0
|
go.opentelemetry.io/otel v1.44.0
|
||||||
@@ -21,11 +22,15 @@ require (
|
|||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.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
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
golang.org/x/net v0.56.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/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.39.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/api v0.0.0-20260706201446-f0a921348800 // indirect
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,20 +1,32 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/handler"
|
"github.com/robindittmar/dttmr-api/internal/api/handler"
|
||||||
"github.com/robindittmar/dttmr-api/internal/api/middleware"
|
"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 {
|
func NewMux(cfg Config) http.Handler {
|
||||||
|
listRepo := repository.NewListRepo(cfg.Database)
|
||||||
|
listService := domain.NewListService(listRepo)
|
||||||
|
|
||||||
|
listHandler := handler.ListHandler{ListService: listService}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("/", handler.DefaultHandler)
|
mux.HandleFunc("/", handler.DefaultHandler)
|
||||||
mux.HandleFunc("GET /health", handler.HealthHandler)
|
mux.HandleFunc("GET /health", handler.HealthHandler)
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /lists", listHandler.CreateList)
|
||||||
|
|
||||||
var httpHandler http.Handler = mux
|
var httpHandler http.Handler = mux
|
||||||
httpHandler = middleware.WithTelemetry(httpHandler)
|
httpHandler = middleware.WithTelemetry(httpHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Environment string
|
||||||
|
Port int
|
||||||
|
OTLPEndpoint string
|
||||||
|
DatabaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
envFlag := flag.String("env", "development", "environment to use")
|
||||||
|
portFlag := flag.Int("port", 8080, "port to listen on")
|
||||||
|
otlpEndpointFlag := flag.String("otlp-endpoint", "localhost:4317", "otlp endpoint")
|
||||||
|
databaseUrlFlag := flag.String("database-url", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", "database connection string")
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Environment: *envFlag,
|
||||||
|
Port: *portFlag,
|
||||||
|
OTLPEndpoint: *otlpEndpointFlag,
|
||||||
|
DatabaseURL: *databaseUrlFlag,
|
||||||
|
}
|
||||||
|
|
||||||
|
assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment)
|
||||||
|
assignIntFromEnv("DTTMR_PORT", &cfg.Port)
|
||||||
|
assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint)
|
||||||
|
assignStringFromEnv("DTTMR_DATABASE_URL", &cfg.DatabaseURL)
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignStringFromEnv(key string, target *string) {
|
||||||
|
if val, exists := os.LookupEnv(key); exists {
|
||||||
|
*target = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignIntFromEnv(key string, target *int) {
|
||||||
|
if val, exists := os.LookupEnv(key); exists {
|
||||||
|
parsed, err := strconv.Atoi(val)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse environment variable", slog.String("var", key), slog.Any("error", err))
|
||||||
|
} else {
|
||||||
|
*target = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(ctx context.Context, connURL string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("pgx", connURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(10)
|
||||||
|
db.SetMaxIdleConns(10)
|
||||||
|
|
||||||
|
db.SetConnMaxLifetime(5 * time.Minute)
|
||||||
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("database unreachable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user