From 2e3e82e776dcea0adbc9f6b7b60dc972547cee48 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 1 Sep 2026 14:55:20 +0200 Subject: [PATCH] feat: added db transactions abstraction; cleaned up user registration; repos adapted new abstraction: all repos now support transactions, if present in ctx --- cmd/bootstrap/main.go | 4 +- internal/api/handler/list.go | 2 +- internal/api/handler/user.go | 44 +++++--------------- internal/api/request/list.go | 3 +- internal/api/router/router.go | 21 ++++------ internal/domain/list.go | 17 +++++--- internal/domain/registration.go | 49 ++++++++++++++++++++++ internal/domain/transactor.go | 7 ++++ internal/repository/auth.go | 18 ++++---- internal/repository/invite.go | 18 ++++---- internal/repository/list.go | 60 +++++++-------------------- internal/repository/repo.go | 73 +++++++++++++++++++++++++++++++++ internal/repository/store.go | 23 +++++++++++ internal/repository/user.go | 25 +++-------- 14 files changed, 219 insertions(+), 145 deletions(-) create mode 100644 internal/domain/registration.go create mode 100644 internal/domain/transactor.go create mode 100644 internal/repository/repo.go create mode 100644 internal/repository/store.go diff --git a/cmd/bootstrap/main.go b/cmd/bootstrap/main.go index 1d087c7..6b69b9e 100644 --- a/cmd/bootstrap/main.go +++ b/cmd/bootstrap/main.go @@ -70,8 +70,8 @@ func main() { } func seedAdminUser(db *sql.DB, email string, name string, password string) error { - userRepo := repository.NewUserRepo(db) - userService := domain.NewUserService(userRepo) + store := repository.NewStore(db) + userService := domain.NewUserService(store.User) _, err := userService.CreateUser(context.Background(), email, name, password) return err diff --git a/internal/api/handler/list.go b/internal/api/handler/list.go index 13f3445..9889809 100644 --- a/internal/api/handler/list.go +++ b/internal/api/handler/list.go @@ -47,7 +47,7 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) { return } - list, err := h.ListService.CreateList(ctx, authContext.UserID, payload.Name, payload.UserIDs) + list, err := h.ListService.CreateList(ctx, authContext.UserID, payload.Name) if err != nil { slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err)) response.Error(ctx, w, http.StatusInternalServerError, "failed to create list") diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index d3be142..0db4a6e 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -10,13 +10,13 @@ import ( ) type UserHandler struct { - UserService *domain.UserService - AuthService *domain.AuthService - InviteService *domain.InviteService + UserService *domain.UserService + AuthService *domain.AuthService + RegistrationService *domain.RegistrationService } -func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, inviteService *domain.InviteService) *UserHandler { - return &UserHandler{UserService: userService, AuthService: authService, InviteService: inviteService} +func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, registrationService *domain.RegistrationService) *UserHandler { + return &UserHandler{UserService: userService, AuthService: authService, RegistrationService: registrationService} } // CreateUser handles the creation of a user @@ -42,41 +42,17 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { return } - invite, err := h.InviteService.GetInvite(ctx, payload.InviteCode) + user, err := h.RegistrationService.Register(ctx, payload.InviteCode, payload.Email, payload.Name, payload.Password) if err != nil { - slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err)) - response.Error(ctx, w, http.StatusBadRequest, "invite is invalid") - return - } - - // TODO: Creating user and consuming the invite must be in a transaction. - // The repositories must support tx in context, and the handler must be able to start a transaction - 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 - } - - err = h.InviteService.ConsumeInvite(ctx, invite.ID, user.ID) - if err != nil { - slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err)) - - // TODO: This should be a transaction rollback, once we have db transactions in the handler - err = h.UserService.DeleteUser(ctx, user.ID) - if err != nil { - slog.ErrorContext(ctx, "failed to delete user again", - slog.Any("error", err), - slog.String("user_id", user.ID), - ) - } - response.Error(ctx, w, http.StatusInternalServerError, "failed to consume invite") + slog.ErrorContext(ctx, "failed to register user", + slog.Any("error", err), + slog.Any("payload", payload)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to register") return } slog.InfoContext(ctx, "created user successfully", slog.String("user_id", user.ID), - slog.String("invite_id", invite.ID), ) response.JSON(ctx, w, http.StatusCreated, user) } diff --git a/internal/api/request/list.go b/internal/api/request/list.go index 00ef7ce..ca8bf1e 100644 --- a/internal/api/request/list.go +++ b/internal/api/request/list.go @@ -1,8 +1,7 @@ package request type CreateListPayload struct { - Name string `json:"name"` - UserIDs []string `json:"user_ids"` + Name string `json:"name"` } type AddUserToListPayload struct { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2f79904..e027ab9 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -16,20 +16,17 @@ type Config struct { } func NewMux(cfg Config) http.Handler { - authRepo := repository.NewAuthRepo(cfg.Database) - authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret)) + store := repository.NewStore(cfg.Database) + + authService := domain.NewAuthService(store.Auth, []byte(cfg.JWTSecret)) + inviteService := domain.NewInviteService(store.Invite) + userService := domain.NewUserService(store.User) + registrationService := domain.NewRegistrationService(store, userService, inviteService) + listService := domain.NewListService(store.List) + authHandler := handler.NewAuthHandler(authService) - - inviteRepo := repository.NewInviteRepo(cfg.Database) - inviteService := domain.NewInviteService(inviteRepo) inviteHandler := handler.NewInviteHandler(inviteService) - - userRepo := repository.NewUserRepo(cfg.Database) - userService := domain.NewUserService(userRepo) - userHandler := handler.NewUserHandler(userService, authService, inviteService) - - listRepo := repository.NewListRepo(cfg.Database) - listService := domain.NewListService(listRepo) + userHandler := handler.NewUserHandler(userService, authService, registrationService) listHandler := handler.NewListHandler(listService, userService) protected := middleware.WithJWT(authService) diff --git a/internal/domain/list.go b/internal/domain/list.go index 92a4665..b7a6aeb 100644 --- a/internal/domain/list.go +++ b/internal/domain/list.go @@ -4,7 +4,6 @@ import ( "context" "errors" "log/slog" - "slices" "time" ) @@ -36,7 +35,7 @@ type ListItem struct { } type ListRepository interface { - CreateList(ctx context.Context, name string, userIDs []string) (*List, error) + CreateList(ctx context.Context, name string) (*List, error) DeleteList(ctx context.Context, listID string) error GetLists(ctx context.Context, userID string) ([]List, error) AddUserToList(ctx context.Context, listID string, userID string) error @@ -58,16 +57,22 @@ func NewListService(r ListRepository) *ListService { return &ListService{repo: r} } -func (s *ListService) CreateList(ctx context.Context, authUserID string, name string, userIDs []string) (*List, error) { +func (s *ListService) CreateList(ctx context.Context, authUserID string, name string) (*List, error) { if name == "" { return nil, ErrListNameEmpty } - if !slices.Contains(userIDs, authUserID) { - userIDs = append(userIDs, authUserID) + list, err := s.repo.CreateList(ctx, name) + if err != nil { + return nil, err } - return s.repo.CreateList(ctx, name, userIDs) + err = s.repo.AddUserToList(ctx, list.ID, authUserID) + if err != nil { + return nil, err + } + + return list, nil } func (s *ListService) DeleteList(ctx context.Context, authUserID string, listID string) error { diff --git a/internal/domain/registration.go b/internal/domain/registration.go new file mode 100644 index 0000000..b136e93 --- /dev/null +++ b/internal/domain/registration.go @@ -0,0 +1,49 @@ +package domain + +import ( + "context" + "log/slog" +) + +type RegistrationService struct { + tx Transactor + UserService *UserService + InviteService *InviteService +} + +func NewRegistrationService(tx Transactor, u *UserService, i *InviteService) *RegistrationService { + return &RegistrationService{tx: tx, UserService: u, InviteService: i} +} + +func (s *RegistrationService) Register(ctx context.Context, inviteCode string, email string, username string, password string) (*User, error) { + invite, err := s.InviteService.GetInvite(ctx, inviteCode) + if err != nil { + slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err)) + return nil, err + } + + var user *User + err = s.tx.WithinTx(ctx, func(ctx context.Context) error { + user, err = s.UserService.CreateUser(ctx, email, username, password) + if err != nil { + slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err)) + return err + } + + err = s.InviteService.ConsumeInvite(ctx, invite.ID, user.ID) + if err != nil { + slog.ErrorContext(ctx, "failed to consume invite", slog.Any("error", err)) + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + slog.InfoContext(ctx, "user registration complete", + slog.String("user_id", user.ID), + slog.String("invite_id", invite.ID)) + return user, nil +} diff --git a/internal/domain/transactor.go b/internal/domain/transactor.go new file mode 100644 index 0000000..7007cb7 --- /dev/null +++ b/internal/domain/transactor.go @@ -0,0 +1,7 @@ +package domain + +import "context" + +type Transactor interface { + WithinTx(ctx context.Context, fn func(ctx context.Context) error) error +} diff --git a/internal/repository/auth.go b/internal/repository/auth.go index 37d50da..70f910a 100644 --- a/internal/repository/auth.go +++ b/internal/repository/auth.go @@ -11,17 +11,13 @@ import ( ) type AuthRepo struct { - db *sql.DB -} - -func NewAuthRepo(db *sql.DB) *AuthRepo { - return &AuthRepo{db: db} + Repo } func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser, error) { user := &domain.AuthUser{} - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT id, email, name, password_hash FROM users WHERE id = $1", id, ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) @@ -35,7 +31,7 @@ func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.AuthUser, error) { user := &domain.AuthUser{} - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT id, email, name, password_hash FROM users WHERE email = $1", email, ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) @@ -47,7 +43,7 @@ func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.Au } func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHash string, expiresAt time.Time) error { - _, err := r.db.ExecContext(ctx, + _, err := r.conn(ctx).ExecContext(ctx, "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", userID, tokenHash, expiresAt, ) @@ -60,7 +56,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) { var userID string - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id", tokenHash, ).Scan(&userID) @@ -75,7 +71,7 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s } func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error { - _, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash) + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM refresh_tokens WHERE token_hash = $1", tokenHash) if err != nil { return fmt.Errorf("failed to revoke refresh token: %w", err) } @@ -84,7 +80,7 @@ func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) err } func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error { - _, err := r.db.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID) + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM refresh_tokens WHERE user_id = $1", userID) if err != nil { return fmt.Errorf("failed to revoke refresh tokens: %w", err) } diff --git a/internal/repository/invite.go b/internal/repository/invite.go index 776b86c..d19d26e 100644 --- a/internal/repository/invite.go +++ b/internal/repository/invite.go @@ -11,16 +11,12 @@ import ( ) type InviteRepo struct { - db *sql.DB -} - -func NewInviteRepo(db *sql.DB) *InviteRepo { - return &InviteRepo{db: db} + Repo } func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) { var id string - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "INSERT INTO invites (inviter_user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id", inviterUserID, code, expiresAt, ).Scan(&id) @@ -37,7 +33,7 @@ func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, cod } func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error { - _, err := r.db.ExecContext(ctx, + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL", inviteID, userID, ) @@ -49,7 +45,7 @@ func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID s } func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error { - res, err := r.db.ExecContext(ctx, + res, err := r.conn(ctx).ExecContext(ctx, "UPDATE invites SET invitee_user_id=$1, consumed_at=NOW() WHERE id=$2 AND expires_at > NOW() AND consumed_at IS NULL", inviteeUserID, inviteID, ) @@ -70,7 +66,7 @@ func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, invitee func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) { var invite domain.Invite - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT id, code, expires_at, consumed_at FROM invites WHERE code=$1", code, ).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt) @@ -82,7 +78,7 @@ func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite } func (r *InviteRepo) GetInvites(ctx context.Context, userID string, offset int, count int) ([]domain.Invite, error) { - rows, err := r.db.QueryContext(ctx, + rows, err := r.conn(ctx).QueryContext(ctx, "SELECT id, code, expires_at, consumed_at FROM invites WHERE inviter_user_id=$1 ORDER BY created_at DESC OFFSET $2 LIMIT $3", userID, offset, count, ) @@ -110,7 +106,7 @@ func (r *InviteRepo) GetInvites(ctx context.Context, userID string, offset int, func (r *InviteRepo) CountInvites(ctx context.Context, userID string) (int, error) { var count int - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1", userID, ).Scan(&count) diff --git a/internal/repository/list.go b/internal/repository/list.go index d58d90a..e588806 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -5,29 +5,18 @@ import ( "database/sql" "errors" "fmt" - "log/slog" "github.com/robindittmar/dttmr-api/internal/domain" ) type ListRepo struct { - db *sql.DB + Repo } -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 tx.Rollback() - +func (r *ListRepo) CreateList(ctx context.Context, name string) (*domain.List, error) { list := &domain.List{Name: name} - err = tx.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at, modified_at", name, ).Scan(&list.ID, &list.CreatedAt, &list.ModifiedAt) @@ -35,32 +24,11 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string 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 { - slog.Error("failed to close user/list association statement", slog.Any("error", err)) - } - }() - - 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, nil } func (r *ListRepo) DeleteList(ctx context.Context, listID string) error { - _, err := r.db.ExecContext(ctx, "DELETE FROM lists WHERE id = $1", listID) + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM lists WHERE id = $1", listID) if err != nil { return fmt.Errorf("failed to delete list: %w", err) } @@ -69,7 +37,7 @@ func (r *ListRepo) DeleteList(ctx context.Context, listID string) error { } func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, error) { - rows, err := r.db.QueryContext(ctx, + rows, err := r.conn(ctx).QueryContext(ctx, "SELECT l.id, l.name, l.created_at, l.modified_at, (SELECT COUNT(*) FROM list_items WHERE list_id=l.id), (SELECT COUNT(*) FROM list_items WHERE list_id=l.id AND is_completed=true) FROM lists AS l INNER JOIN list_users ON l.id=list_users.list_id WHERE list_users.user_id = $1", userID, ) @@ -96,7 +64,7 @@ func (r *ListRepo) GetLists(ctx context.Context, userID string) ([]domain.List, } func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID string) error { - _, err := r.db.ExecContext(ctx, + _, err := r.conn(ctx).ExecContext(ctx, "INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)", listID, userID, ) @@ -108,7 +76,7 @@ func (r *ListRepo) AddUserToList(ctx context.Context, listID string, userID stri } func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID string) error { - _, err := r.db.ExecContext(ctx, + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM list_users WHERE list_id = $1 AND user_id = $2", listID, userID, ) @@ -122,7 +90,7 @@ func (r *ListRepo) RemoveUserFromList(ctx context.Context, listID string, userID func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) { var cnt int - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT COUNT(*) FROM list_users WHERE list_id = $1 AND user_id = $2", listID, userID, ).Scan(&cnt) @@ -136,7 +104,7 @@ func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID strin func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) { var cnt int - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT COUNT(*) FROM list_users WHERE list_id = (SELECT list_id FROM list_items WHERE id = $1) AND user_id = $2", listItemID, userID, ).Scan(&cnt) @@ -150,7 +118,7 @@ func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) { l := &domain.ListItem{Title: title} - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "INSERT INTO list_items (list_id, title) VALUES ($1, $2) RETURNING id, is_completed, created_at, modified_at", listID, title, ).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt) @@ -162,7 +130,7 @@ func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title stri } func (r *ListRepo) DeleteListItem(ctx context.Context, listItemID string) error { - _, err := r.db.ExecContext(ctx, "DELETE FROM list_items WHERE id = $1", listItemID) + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM list_items WHERE id = $1", listItemID) if err != nil { return fmt.Errorf("failed to delete list item: %w", err) } @@ -171,7 +139,7 @@ func (r *ListRepo) DeleteListItem(ctx context.Context, listItemID string) error } func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title string, isCompleted bool) error { - _, err := r.db.ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3", + _, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET title = $1, is_completed = $2, modified_at = NOW() WHERE id = $3", title, isCompleted, listItemID, ) if err != nil { @@ -182,7 +150,7 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title } func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error { - _, err := r.db.ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2", + _, err := r.conn(ctx).ExecContext(ctx, "UPDATE list_items SET is_completed = $1, modified_at = NOW() WHERE id = $2", isCompleted, listItemID, ) if err != nil { @@ -193,7 +161,7 @@ func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, } func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) { - rows, err := r.db.QueryContext(ctx, + rows, err := r.conn(ctx).QueryContext(ctx, "SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1 ORDER BY is_completed, modified_at DESC", listID, ) diff --git a/internal/repository/repo.go b/internal/repository/repo.go new file mode 100644 index 0000000..b5cefb9 --- /dev/null +++ b/internal/repository/repo.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + "sync/atomic" +) + +type Transaction interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row + PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) +} + +type Repo struct { + *Transactor +} + +func NewRepo(t *Transactor) Repo { + return Repo{t} +} + +func (r *Repo) conn(ctx context.Context) Transaction { + if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok { + return tx + } + return r.db +} + +type txKey struct{} + +type Transactor struct { + db *sql.DB + sp atomic.Uint64 +} + +func NewTransactor(db *sql.DB) *Transactor { + return &Transactor{db: db, sp: atomic.Uint64{}} +} + +func (t *Transactor) WithinTx(ctx context.Context, fn func(context.Context) error) error { + if tx, ok := ctx.Value(txKey{}).(*sql.Tx); ok { + return t.withinSavepoint(ctx, tx, fn) + } + + tx, err := t.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if err = fn(ctx); err != nil { + return err + } + return tx.Commit() +} + +func (t *Transactor) withinSavepoint(ctx context.Context, tx *sql.Tx, fn func(context.Context) error) error { + name := fmt.Sprintf("sp_%d", t.sp.Add(1)) + if _, err := tx.ExecContext(ctx, "SAVEPOINT "+name); err != nil { + return err + } + + if err := fn(ctx); err != nil { + _, _ = tx.ExecContext(ctx, "ROLLBACK TO SAVEPOINT"+name) + return err + } + + _, err := tx.ExecContext(ctx, "RELEASE SAVEPOINT"+name) + return err +} diff --git a/internal/repository/store.go b/internal/repository/store.go new file mode 100644 index 0000000..27bade3 --- /dev/null +++ b/internal/repository/store.go @@ -0,0 +1,23 @@ +package repository + +import "database/sql" + +type Store struct { + *Transactor + Auth *AuthRepo + Invite *InviteRepo + List *ListRepo + User *UserRepo +} + +func NewStore(db *sql.DB) *Store { + t := NewTransactor(db) + r := NewRepo(t) + return &Store{ + Transactor: t, + Auth: &AuthRepo{r}, + Invite: &InviteRepo{r}, + List: &ListRepo{r}, + User: &UserRepo{r}, + } +} diff --git a/internal/repository/user.go b/internal/repository/user.go index e385fb1..798b5fb 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -2,30 +2,19 @@ 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} + Repo } 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, + err := r.conn(ctx).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) @@ -33,15 +22,11 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa 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, nil } func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error { - _, err := r.db.ExecContext(ctx, + _, err := r.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", userID, ) @@ -53,7 +38,7 @@ func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error { } func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error { - _, err := r.db.ExecContext(ctx, + _, err := r.conn(ctx).ExecContext(ctx, "UPDATE users SET password_hash = $1 WHERE id = $2", passwordHash, userID, ) @@ -67,7 +52,7 @@ func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHa func (r *UserRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { user := &domain.User{} - err := r.db.QueryRowContext(ctx, + err := r.conn(ctx).QueryRowContext(ctx, "SELECT id, email, name FROM users WHERE email = $1", email, ).Scan(&user.ID, &user.Email, &user.Name)