Added transactions and improved error handling #35

Merged
robin merged 9 commits from dev into main 2026-09-01 15:58:38 +02:00
17 changed files with 290 additions and 172 deletions
+2 -2
View File
@@ -70,8 +70,8 @@ func main() {
} }
func seedAdminUser(db *sql.DB, email string, name string, password string) error { func seedAdminUser(db *sql.DB, email string, name string, password string) error {
userRepo := repository.NewUserRepo(db) store := repository.NewStore(db)
userService := domain.NewUserService(userRepo) userService := domain.NewUserService(store.User)
_, err := userService.CreateUser(context.Background(), email, name, password) _, err := userService.CreateUser(context.Background(), email, name, password)
return err return err
+12 -1
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -27,6 +28,8 @@ func NewAuthHandler(authService *domain.AuthService) *AuthHandler {
// @Param payload body request.LoginPayload true "Login payload" // @Param payload body request.LoginPayload true "Login payload"
// @Success 200 {object} domain.TokenPair // @Success 200 {object} domain.TokenPair
// @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "email not found"
// @Error 401 {object} response.ErrorResponse "password is wrong"
// @Error 500 {object} response.ErrorResponse "failed to login" // @Error 500 {object} response.ErrorResponse "failed to login"
// @Router /login [post] // @Router /login [post]
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
@@ -41,8 +44,15 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
tokens, err := h.AuthService.Login(ctx, payload.Email, payload.Password) tokens, err := h.AuthService.Login(ctx, payload.Email, payload.Password)
if err != nil { if err != nil {
if errors.Is(err, domain.ErrEmailNotFound) {
response.Error(ctx, w, http.StatusUnauthorized, "email not found")
} else if errors.Is(err, domain.ErrPasswordWrong) {
response.Error(ctx, w, http.StatusUnauthorized, "password is wrong")
} else {
response.Error(ctx, w, http.StatusInternalServerError, "failed to login")
}
slog.ErrorContext(ctx, "failed to login", slog.Any("error", err)) slog.ErrorContext(ctx, "failed to login", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to login")
return return
} }
@@ -89,6 +99,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
// @Tags Authorization // @Tags Authorization
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param payload body request.LogoutPayload true "Logout payload"
// @Success 200 {object} nil // @Success 200 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to logout" // @Error 500 {object} response.ErrorResponse "failed to logout"
+4 -4
View File
@@ -47,7 +47,7 @@ func (h *ListHandler) CreateList(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err)) slog.ErrorContext(ctx, "failed to create list", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to create list") response.Error(ctx, w, http.StatusInternalServerError, "failed to create list")
@@ -243,7 +243,7 @@ func (h *ListHandler) RemoveUserFromList(w http.ResponseWriter, r *http.Request)
// @Success 204 {object} nil // @Success 204 {object} nil
// @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "failed to create list item" // @Error 500 {object} response.ErrorResponse "failed to create list item"
// @Router /lists/item [post] // @Router /lists/items [post]
func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) { func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -283,7 +283,7 @@ func (h *ListHandler) CreateListItem(w http.ResponseWriter, r *http.Request) {
// @Success 204 // @Success 204
// @Error 400 {object} response.ErrorResponse "failed to decode request url" // @Error 400 {object} response.ErrorResponse "failed to decode request url"
// @Error 500 {object} response.ErrorResponse "failed to delete list item" // @Error 500 {object} response.ErrorResponse "failed to delete list item"
// @Router /lists/item/{id} [delete] // @Router /lists/items/{id} [delete]
func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) { func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -324,7 +324,7 @@ func (h *ListHandler) DeleteListItem(w http.ResponseWriter, r *http.Request) {
// @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 401 {object} response.ErrorResponse "not authorized" // @Error 401 {object} response.ErrorResponse "not authorized"
// @Error 500 {object} response.ErrorResponse "failed to update list item" // @Error 500 {object} response.ErrorResponse "failed to update list item"
// @Router /lists/item [put] // @Router /lists/items [put]
func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) { func (h *ListHandler) UpdateListItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
+20 -32
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -10,13 +11,13 @@ import (
) )
type UserHandler struct { type UserHandler struct {
UserService *domain.UserService UserService *domain.UserService
AuthService *domain.AuthService AuthService *domain.AuthService
InviteService *domain.InviteService RegistrationService *domain.RegistrationService
} }
func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, inviteService *domain.InviteService) *UserHandler { func NewUserHandler(userService *domain.UserService, authService *domain.AuthService, registrationService *domain.RegistrationService) *UserHandler {
return &UserHandler{UserService: userService, AuthService: authService, InviteService: inviteService} return &UserHandler{UserService: userService, AuthService: authService, RegistrationService: registrationService}
} }
// CreateUser handles the creation of a user // CreateUser handles the creation of a user
@@ -29,6 +30,8 @@ func NewUserHandler(userService *domain.UserService, authService *domain.AuthSer
// @Param payload body request.CreateUserPayload true "Create user payload" // @Param payload body request.CreateUserPayload true "Create user payload"
// @Success 201 {object} domain.User // @Success 201 {object} domain.User
// @Error 400 {object} response.ErrorResponse "failed to decode request body" // @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 400 {object} response.ErrorResponse "invite is expired"
// @Error 409 {object} response.ErrorResponse "invite is already consumed"
// @Error 400 {object} response.ErrorResponse "invite is invalid" // @Error 400 {object} response.ErrorResponse "invite is invalid"
// @Error 500 {object} response.ErrorResponse "failed to create user" // @Error 500 {object} response.ErrorResponse "failed to create user"
// @Router /users [post] // @Router /users [post]
@@ -42,41 +45,26 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
slog.ErrorContext(ctx, "failed to get invite", slog.Any("error", err)) slog.ErrorContext(ctx, "failed to register user",
response.Error(ctx, w, http.StatusBadRequest, "invite is invalid") slog.Any("error", err),
return slog.Any("payload", payload))
}
// TODO: Creating user and consuming the invite must be in a transaction. if errors.Is(err, domain.ErrInviteExpired) {
// The repositories must support tx in context, and the handler must be able to start a transaction response.Error(ctx, w, http.StatusBadRequest, "invite is expired")
user, err := h.UserService.CreateUser(ctx, payload.Email, payload.Name, payload.Password) } else if errors.Is(err, domain.ErrInviteConsumed) {
if err != nil { response.Error(ctx, w, http.StatusConflict, "invite is already consumed")
slog.ErrorContext(ctx, "failed to create user", slog.Any("error", err)) } else if errors.Is(err, domain.ErrInviteInvalid) {
response.Error(ctx, w, http.StatusInternalServerError, "failed to create user") response.Error(ctx, w, http.StatusBadRequest, "invite is invalid")
return } else {
} response.Error(ctx, w, http.StatusInternalServerError, "failed to register")
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")
return return
} }
slog.InfoContext(ctx, "created user successfully", slog.InfoContext(ctx, "created user successfully",
slog.String("user_id", user.ID), slog.String("user_id", user.ID),
slog.String("invite_id", invite.ID),
) )
response.JSON(ctx, w, http.StatusCreated, user) response.JSON(ctx, w, http.StatusCreated, user)
} }
+1 -2
View File
@@ -1,8 +1,7 @@
package request package request
type CreateListPayload struct { type CreateListPayload struct {
Name string `json:"name"` Name string `json:"name"`
UserIDs []string `json:"user_ids"`
} }
type AddUserToListPayload struct { type AddUserToListPayload struct {
+13 -16
View File
@@ -16,20 +16,17 @@ type Config struct {
} }
func NewMux(cfg Config) http.Handler { func NewMux(cfg Config) http.Handler {
authRepo := repository.NewAuthRepo(cfg.Database) store := repository.NewStore(cfg.Database)
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
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) authHandler := handler.NewAuthHandler(authService)
inviteRepo := repository.NewInviteRepo(cfg.Database)
inviteService := domain.NewInviteService(inviteRepo)
inviteHandler := handler.NewInviteHandler(inviteService) inviteHandler := handler.NewInviteHandler(inviteService)
userHandler := handler.NewUserHandler(userService, authService, registrationService)
userRepo := repository.NewUserRepo(cfg.Database)
userService := domain.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService, authService, inviteService)
listRepo := repository.NewListRepo(cfg.Database)
listService := domain.NewListService(listRepo)
listHandler := handler.NewListHandler(listService, userService) listHandler := handler.NewListHandler(listService, userService)
protected := middleware.WithJWT(authService) protected := middleware.WithJWT(authService)
@@ -41,7 +38,7 @@ func NewMux(cfg Config) http.Handler {
apiMux.HandleFunc("POST /login", authHandler.Login) apiMux.HandleFunc("POST /login", authHandler.Login)
apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh) apiMux.HandleFunc("POST /login/refresh", authHandler.Refresh)
apiMux.HandleFunc("POST /logout", authHandler.Logout) apiMux.HandleFunc("POST /logout", authHandler.Logout)
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices) apiMux.HandleFunc("POST /logout/all", protected(authHandler.LogoutAllDevices))
// Users // Users
apiMux.HandleFunc("POST /users", userHandler.CreateUser) apiMux.HandleFunc("POST /users", userHandler.CreateUser)
@@ -60,9 +57,9 @@ func NewMux(cfg Config) http.Handler {
apiMux.Handle("GET /lists", protected(listHandler.GetLists)) apiMux.Handle("GET /lists", protected(listHandler.GetLists))
apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList)) apiMux.Handle("POST /lists/user", protected(listHandler.AddUserToList))
apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList)) apiMux.Handle("DELETE /lists/user", protected(listHandler.RemoveUserFromList))
apiMux.Handle("POST /lists/item", protected(listHandler.CreateListItem)) apiMux.Handle("POST /lists/items", protected(listHandler.CreateListItem))
apiMux.Handle("DELETE /lists/item/{id}", protected(listHandler.DeleteListItem)) apiMux.Handle("DELETE /lists/items/{id}", protected(listHandler.DeleteListItem))
apiMux.Handle("PUT /lists/item", protected(listHandler.UpdateListItem)) apiMux.Handle("PUT /lists/items", protected(listHandler.UpdateListItem))
apiMux.Handle("POST /lists/items/{id}", protected(listHandler.SetListItemCompleted)) apiMux.Handle("POST /lists/items/{id}", protected(listHandler.SetListItemCompleted))
apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems)) apiMux.Handle("GET /lists/{id}", protected(listHandler.GetListItems))
+6 -1
View File
@@ -14,6 +14,11 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var (
ErrEmailNotFound = errors.New("email not found")
ErrPasswordWrong = errors.New("password is wrong")
)
type AuthRepository interface { type AuthRepository interface {
GetUserById(ctx context.Context, id string) (*AuthUser, error) GetUserById(ctx context.Context, id string) (*AuthUser, error)
GetUserByEmail(ctx context.Context, email string) (*AuthUser, error) GetUserByEmail(ctx context.Context, email string) (*AuthUser, error)
@@ -71,7 +76,7 @@ func (s *AuthService) Authenticate(ctx context.Context, email string, password s
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
if err != nil { if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return user, errors.New("invalid email or password") return user, ErrPasswordWrong
} }
return user, err return user, err
} }
+1
View File
@@ -9,6 +9,7 @@ import (
var ( var (
ErrInviteIDMissing = errors.New("invite id is required") ErrInviteIDMissing = errors.New("invite id is required")
ErrCodeMissing = errors.New("invite code is required") ErrCodeMissing = errors.New("invite code is required")
ErrInviteInvalid = errors.New("invite is invalid")
ErrInviteExpired = errors.New("invite is expired") ErrInviteExpired = errors.New("invite is expired")
ErrInviteConsumed = errors.New("invite is already consumed") ErrInviteConsumed = errors.New("invite is already consumed")
) )
+29 -25
View File
@@ -4,17 +4,15 @@ import (
"context" "context"
"errors" "errors"
"log/slog" "log/slog"
"slices"
"time" "time"
) )
var ( var (
ErrListIDEmpty = errors.New("list id must not be empty") ErrListIDMissing = errors.New("list id is required")
ErrListNameEmpty = errors.New("list name must not be empty") ErrListNameMissing = errors.New("list name is required")
ErrUserIDEmpty = errors.New("user id must not be empty") ErrListItemIDMissing = errors.New("list item id is required")
ErrListItemIDEmpty = errors.New("list item id must not be empty") ErrListItemTitleMissing = errors.New("list item title is required")
ErrListItemTitleEmpty = errors.New("list item title must not be empty") ErrUserNotInList = errors.New("user not in list")
ErrUserNotInList = errors.New("user not in list")
) )
type List struct { type List struct {
@@ -36,7 +34,7 @@ type ListItem struct {
} }
type ListRepository interface { 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 DeleteList(ctx context.Context, listID string) error
GetLists(ctx context.Context, userID string) ([]List, error) GetLists(ctx context.Context, userID string) ([]List, error)
AddUserToList(ctx context.Context, listID string, userID string) error AddUserToList(ctx context.Context, listID string, userID string) error
@@ -58,21 +56,27 @@ func NewListService(r ListRepository) *ListService {
return &ListService{repo: r} 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 == "" { if name == "" {
return nil, ErrListNameEmpty return nil, ErrListNameMissing
} }
if !slices.Contains(userIDs, authUserID) { list, err := s.repo.CreateList(ctx, name)
userIDs = append(userIDs, authUserID) 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 { func (s *ListService) DeleteList(ctx context.Context, authUserID string, listID string) error {
if listID == "" { if listID == "" {
return ErrListIDEmpty return ErrListIDMissing
} }
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil { if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -88,10 +92,10 @@ func (s *ListService) GetLists(ctx context.Context, authUserID string) ([]List,
func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error { func (s *ListService) AddUserToList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" { if listID == "" {
return ErrListIDEmpty return ErrListIDMissing
} }
if userID == "" { if userID == "" {
return ErrUserIDEmpty return ErrUserIDMissing
} }
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil { if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -103,10 +107,10 @@ func (s *ListService) AddUserToList(ctx context.Context, authUserID string, list
func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error { func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string, listID string, userID string) error {
if listID == "" { if listID == "" {
return ErrListIDEmpty return ErrListIDMissing
} }
if userID == "" { if userID == "" {
return ErrUserIDEmpty return ErrUserIDMissing
} }
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil { if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -118,10 +122,10 @@ func (s *ListService) RemoveUserFromList(ctx context.Context, authUserID string,
func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) { func (s *ListService) CreateListItem(ctx context.Context, authUserID string, listID string, title string) (*ListItem, error) {
if listID == "" { if listID == "" {
return nil, ErrListIDEmpty return nil, ErrListIDMissing
} }
if title == "" { if title == "" {
return nil, ErrListItemTitleEmpty return nil, ErrListItemTitleMissing
} }
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil { if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
@@ -133,7 +137,7 @@ func (s *ListService) CreateListItem(ctx context.Context, authUserID string, lis
func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, listItemID string) error { func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, listItemID string) error {
if listItemID == "" { if listItemID == "" {
return ErrListItemIDEmpty return ErrListItemIDMissing
} }
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil { if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
@@ -145,10 +149,10 @@ func (s *ListService) DeleteListItem(ctx context.Context, authUserID string, lis
func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error { func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, listItemID string, title string, isCompleted bool) error {
if listItemID == "" { if listItemID == "" {
return ErrListItemIDEmpty return ErrListItemIDMissing
} }
if title == "" { if title == "" {
return ErrListItemTitleEmpty return ErrListItemTitleMissing
} }
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil { if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
@@ -160,7 +164,7 @@ func (s *ListService) UpdateListItem(ctx context.Context, authUserID string, lis
func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error { func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID string, listItemID string, isCompleted bool) error {
if listItemID == "" { if listItemID == "" {
return ErrListItemIDEmpty return ErrListItemIDMissing
} }
if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil { if err := s.userAllowedToAccessListItem(ctx, authUserID, listItemID); err != nil {
@@ -172,7 +176,7 @@ func (s *ListService) SetListItemCompleted(ctx context.Context, authUserID strin
func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) { func (s *ListService) GetListItems(ctx context.Context, authUserID string, listID string) ([]ListItem, error) {
if listID == "" { if listID == "" {
return nil, ErrListIDEmpty return nil, ErrListIDMissing
} }
if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil { if err := s.userAllowedToAccessList(ctx, authUserID, listID); err != nil {
+49
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
package domain
import "context"
type Transactor interface {
WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
}
+10 -11
View File
@@ -11,17 +11,13 @@ import (
) )
type AuthRepo struct { type AuthRepo struct {
db *sql.DB Repo
}
func NewAuthRepo(db *sql.DB) *AuthRepo {
return &AuthRepo{db: db}
} }
func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser, error) { func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser, error) {
user := &domain.AuthUser{} 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", "SELECT id, email, name, password_hash FROM users WHERE id = $1",
id, id,
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
@@ -35,11 +31,14 @@ func (r *AuthRepo) GetUserById(ctx context.Context, id string) (*domain.AuthUser
func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.AuthUser, error) { func (r *AuthRepo) GetUserByEmail(ctx context.Context, email string) (*domain.AuthUser, error) {
user := &domain.AuthUser{} 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", "SELECT id, email, name, password_hash FROM users WHERE email = $1",
email, email,
).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrEmailNotFound
}
return nil, fmt.Errorf("failed to get user: %w", err) return nil, fmt.Errorf("failed to get user: %w", err)
} }
@@ -47,7 +46,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 { 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)", "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
userID, tokenHash, expiresAt, userID, tokenHash, expiresAt,
) )
@@ -60,7 +59,7 @@ func (r *AuthRepo) StoreRefreshToken(ctx context.Context, userID string, tokenHa
func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) { func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string 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", "DELETE FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id",
tokenHash, tokenHash,
).Scan(&userID) ).Scan(&userID)
@@ -75,7 +74,7 @@ func (r *AuthRepo) ConsumeRefreshToken(ctx context.Context, tokenHash string) (s
} }
func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) error { 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 { if err != nil {
return fmt.Errorf("failed to revoke refresh token: %w", err) return fmt.Errorf("failed to revoke refresh token: %w", err)
} }
@@ -84,7 +83,7 @@ func (r *AuthRepo) RevokeRefreshToken(ctx context.Context, tokenHash string) err
} }
func (r *AuthRepo) RevokeRefreshTokens(ctx context.Context, userID string) error { 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 { if err != nil {
return fmt.Errorf("failed to revoke refresh tokens: %w", err) return fmt.Errorf("failed to revoke refresh tokens: %w", err)
} }
+20 -12
View File
@@ -11,16 +11,12 @@ import (
) )
type InviteRepo struct { type InviteRepo struct {
db *sql.DB Repo
}
func NewInviteRepo(db *sql.DB) *InviteRepo {
return &InviteRepo{db: db}
} }
func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) { func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, code string, expiresAt time.Time) (*domain.Invite, error) {
var id string 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", "INSERT INTO invites (inviter_user_id, code, expires_at) VALUES ($1, $2, $3) RETURNING id",
inviterUserID, code, expiresAt, inviterUserID, code, expiresAt,
).Scan(&id) ).Scan(&id)
@@ -37,19 +33,28 @@ func (r *InviteRepo) CreateInvite(ctx context.Context, inviterUserID string, cod
} }
func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error { func (r *InviteRepo) DeleteInvite(ctx context.Context, userID string, inviteID string) error {
_, err := r.db.ExecContext(ctx, res, err := r.conn(ctx).ExecContext(ctx,
"DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL", "DELETE FROM invites WHERE id = $1 AND inviter_user_id = $2 AND consumed_at IS NULL",
inviteID, userID, inviteID, userID,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to delete invite: %w", err) return fmt.Errorf("failed to delete invite: %w", err)
} }
affected, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("could not get rows affected: %w", err)
}
if affected < 1 {
// It's more of an assumption,
// but unless I encounter this being wrong, I'll keep it.
return domain.ErrInviteConsumed
}
return nil return nil
} }
func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, inviteeUserID string) error { 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", "UPDATE invites SET invitee_user_id=$1, consumed_at=NOW() WHERE id=$2 AND expires_at > NOW() AND consumed_at IS NULL",
inviteeUserID, inviteID, inviteeUserID, inviteID,
) )
@@ -61,7 +66,7 @@ func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, invitee
return fmt.Errorf("could not get rows affected: %w", err) return fmt.Errorf("could not get rows affected: %w", err)
} }
if affected < 1 { if affected < 1 {
return fmt.Errorf("invite not found or expired") return domain.ErrInviteInvalid
} }
return nil return nil
@@ -70,11 +75,14 @@ func (r *InviteRepo) ConsumeInvite(ctx context.Context, inviteID string, invitee
func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) { func (r *InviteRepo) GetInvite(ctx context.Context, code string) (*domain.Invite, error) {
var invite domain.Invite 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", "SELECT id, code, expires_at, consumed_at FROM invites WHERE code=$1",
code, code,
).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt) ).Scan(&invite.ID, &invite.Code, &invite.ExpiresAt, &invite.ConsumedAt)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrInviteInvalid
}
return nil, fmt.Errorf("failed to get invite: %w", err) return nil, fmt.Errorf("failed to get invite: %w", err)
} }
@@ -82,7 +90,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) { 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", "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, userID, offset, count,
) )
@@ -110,7 +118,7 @@ func (r *InviteRepo) GetInvites(ctx context.Context, userID string, offset int,
func (r *InviteRepo) CountInvites(ctx context.Context, userID string) (int, error) { func (r *InviteRepo) CountInvites(ctx context.Context, userID string) (int, error) {
var count int var count int
err := r.db.QueryRowContext(ctx, err := r.conn(ctx).QueryRowContext(ctx,
"SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1", "SELECT COUNT(*) FROM invites WHERE inviter_user_id=$1",
userID, userID,
).Scan(&count) ).Scan(&count)
+15 -46
View File
@@ -5,29 +5,18 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"github.com/robindittmar/dttmr-api/internal/domain" "github.com/robindittmar/dttmr-api/internal/domain"
) )
type ListRepo struct { type ListRepo struct {
db *sql.DB Repo
} }
func NewListRepo(db *sql.DB) *ListRepo { func (r *ListRepo) CreateList(ctx context.Context, name string) (*domain.List, error) {
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()
list := &domain.List{Name: name} 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", "INSERT INTO lists (name) VALUES ($1) RETURNING id, created_at, modified_at",
name, name,
).Scan(&list.ID, &list.CreatedAt, &list.ModifiedAt) ).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) 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 return list, nil
} }
func (r *ListRepo) DeleteList(ctx context.Context, listID string) error { 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 { if err != nil {
return fmt.Errorf("failed to delete list: %w", err) 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) { 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", "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, 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 { 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)", "INSERT INTO list_users (list_id, user_id) VALUES ($1, $2)",
listID, userID, 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 { 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", "DELETE FROM list_users WHERE list_id = $1 AND user_id = $2",
listID, userID, 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) { func (r *ListRepo) IsUserInList(ctx context.Context, listID string, userID string) (bool, error) {
var cnt int 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", "SELECT COUNT(*) FROM list_users WHERE list_id = $1 AND user_id = $2",
listID, userID, listID, userID,
).Scan(&cnt) ).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) { func (r *ListRepo) IsUserInListByItemID(ctx context.Context, listItemID string, userID string) (bool, error) {
var cnt int 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", "SELECT COUNT(*) FROM list_users WHERE list_id = (SELECT list_id FROM list_items WHERE id = $1) AND user_id = $2",
listItemID, userID, listItemID, userID,
).Scan(&cnt) ).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) { func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title string) (*domain.ListItem, error) {
l := &domain.ListItem{Title: title} 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", "INSERT INTO list_items (list_id, title) VALUES ($1, $2) RETURNING id, is_completed, created_at, modified_at",
listID, title, listID, title,
).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt) ).Scan(&l.ID, &l.IsCompleted, &l.CreatedAt, &l.ModifiedAt)
@@ -158,11 +126,12 @@ func (r *ListRepo) CreateListItem(ctx context.Context, listID string, title stri
return nil, fmt.Errorf("failed to insert list item: %w", err) return nil, fmt.Errorf("failed to insert list item: %w", err)
} }
l.ListID = listID
return l, nil return l, nil
} }
func (r *ListRepo) DeleteListItem(ctx context.Context, listItemID string) error { 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 { if err != nil {
return fmt.Errorf("failed to delete list item: %w", err) return fmt.Errorf("failed to delete list item: %w", err)
} }
@@ -171,7 +140,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 { 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, title, isCompleted, listItemID,
) )
if err != nil { if err != nil {
@@ -182,7 +151,7 @@ func (r *ListRepo) UpdateListItem(ctx context.Context, listItemID string, title
} }
func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string, isCompleted bool) error { 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, isCompleted, listItemID,
) )
if err != nil { if err != nil {
@@ -193,7 +162,7 @@ func (r *ListRepo) SetListItemCompleted(ctx context.Context, listItemID string,
} }
func (r *ListRepo) GetListItems(ctx context.Context, listID string) ([]domain.ListItem, error) { 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", "SELECT id, title, is_completed, created_at, modified_at FROM list_items WHERE list_id = $1 ORDER BY is_completed, modified_at DESC",
listID, listID,
) )
+73
View File
@@ -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
}
+23
View File
@@ -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},
}
}
+5 -20
View File
@@ -2,30 +2,19 @@ package repository
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"github.com/robindittmar/dttmr-api/internal/domain" "github.com/robindittmar/dttmr-api/internal/domain"
) )
type UserRepo struct { type UserRepo struct {
db *sql.DB Repo
}
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) { 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} 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", "INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, created_at",
email, name, passwordHash, email, name, passwordHash,
).Scan(&user.ID, &user.CreatedAt) ).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) 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 return user, nil
} }
func (r *UserRepo) DeleteUser(ctx context.Context, userID string) error { 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", "DELETE FROM users WHERE id = $1",
userID, 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 { 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", "UPDATE users SET password_hash = $1 WHERE id = $2",
passwordHash, userID, 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) { func (r *UserRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
user := &domain.User{} user := &domain.User{}
err := r.db.QueryRowContext(ctx, err := r.conn(ctx).QueryRowContext(ctx,
"SELECT id, email, name FROM users WHERE email = $1", "SELECT id, email, name FROM users WHERE email = $1",
email, email,
).Scan(&user.ID, &user.Email, &user.Name) ).Scan(&user.ID, &user.Email, &user.Name)