chore: overhaul onboarding steps
This commit is contained in:
parent
acb7f511f9
commit
d413193366
12 changed files with 358 additions and 186 deletions
58
internal/service/account.go
Normal file
58
internal/service/account.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const DefaultAccountName = "Money Account"
|
||||
|
||||
type AccountService struct {
|
||||
accountRepo repository.AccountRepository
|
||||
}
|
||||
|
||||
func NewAccountService(accountRepo repository.AccountRepository) *AccountService {
|
||||
return &AccountService{accountRepo: accountRepo}
|
||||
}
|
||||
|
||||
func (s *AccountService) CreateAccount(spaceID, name string) (*model.Account, error) {
|
||||
if spaceID == "" {
|
||||
return nil, fmt.Errorf("space id is required")
|
||||
}
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("account name cannot be empty")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
account := &model.Account{
|
||||
ID: uuid.NewString(),
|
||||
Name: name,
|
||||
SpaceID: spaceID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.accountRepo.Create(account); err != nil {
|
||||
return nil, fmt.Errorf("failed to create account: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *AccountService) GetAccount(id string) (*model.Account, error) {
|
||||
account, err := s.accountRepo.ByID(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (s *AccountService) GetAccountsForSpace(spaceID string) ([]*model.Account, error) {
|
||||
accounts, err := s.accountRepo.BySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get accounts for space: %w", err)
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ type AuthService struct {
|
|||
userRepository repository.UserRepository
|
||||
tokenRepository repository.TokenRepository
|
||||
spaceService *SpaceService
|
||||
accountService *AccountService
|
||||
jwtSecret string
|
||||
jwtExpiry time.Duration
|
||||
tokenMagicLinkExpiry time.Duration
|
||||
|
|
@ -47,20 +48,22 @@ func NewAuthService(
|
|||
userRepository repository.UserRepository,
|
||||
tokenRepository repository.TokenRepository,
|
||||
spaceService *SpaceService,
|
||||
accountService *AccountService,
|
||||
jwtSecret string,
|
||||
jwtExpiry time.Duration,
|
||||
tokenMagicLinkExpiry time.Duration,
|
||||
isProduction bool,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
emailService: emailService,
|
||||
userRepository: userRepository,
|
||||
tokenRepository: tokenRepository,
|
||||
spaceService: spaceService,
|
||||
jwtSecret: jwtSecret,
|
||||
jwtExpiry: jwtExpiry,
|
||||
emailService: emailService,
|
||||
userRepository: userRepository,
|
||||
tokenRepository: tokenRepository,
|
||||
spaceService: spaceService,
|
||||
accountService: accountService,
|
||||
jwtSecret: jwtSecret,
|
||||
jwtExpiry: jwtExpiry,
|
||||
tokenMagicLinkExpiry: tokenMagicLinkExpiry,
|
||||
isProduction: isProduction,
|
||||
isProduction: isProduction,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,12 +334,16 @@ func (s *AuthService) NeedsOnboarding(userID string) (bool, error) {
|
|||
return user.Name == nil || *user.Name == "", nil
|
||||
}
|
||||
|
||||
// CompleteOnboarding sets the user's name during onboarding
|
||||
// CompleteOnboarding finalizes a user's onboarding by provisioning their first
|
||||
// space and its default account, then saving their display name.
|
||||
//
|
||||
// The user-name update happens LAST so that if any step fails partway through,
|
||||
// NeedsOnboarding still returns true and the user is routed back to retry.
|
||||
// A retry is idempotent: if the user already has a space, the provisioning
|
||||
// steps are skipped and only the name update runs.
|
||||
func (s *AuthService) CompleteOnboarding(userID, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
err := validation.ValidateName(name)
|
||||
if err != nil {
|
||||
if err := validation.ValidateName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -345,18 +352,38 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
|
|||
return fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
user.Name = &name
|
||||
err = s.userRepository.Update(user)
|
||||
existing, err := s.spaceService.GetSpacesForUser(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing spaces: %w", err)
|
||||
}
|
||||
|
||||
if len(existing) == 0 {
|
||||
spaceName := name + "'s Space"
|
||||
space, err := s.spaceService.CreateSpace(spaceName, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create onboarding space: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.accountService.CreateAccount(space.ID, DefaultAccountName); err != nil {
|
||||
if delErr := s.spaceService.DeleteSpace(space.ID); delErr != nil {
|
||||
slog.Error("failed to roll back space after account creation error",
|
||||
"space_id", space.ID, "error", delErr)
|
||||
}
|
||||
return fmt.Errorf("failed to create default account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
user.Name = &name
|
||||
if err := s.userRepository.Update(user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
err = s.emailService.SendWelcomeEmail(user.Email, name)
|
||||
if err != nil {
|
||||
if err := s.emailService.SendWelcomeEmail(user.Email, name); err != nil {
|
||||
slog.Warn("failed to send welcome email", "error", err, "email", user.Email)
|
||||
}
|
||||
|
||||
slog.Info("onboarding completed", "user_id", user.ID, "name", name)
|
||||
slog.Info("onboarding completed",
|
||||
"user_id", user.ID, "name", name, "provisioned_space", len(existing) == 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ func newTestAuthService(dbi testutil.DBInfo) *AuthService {
|
|||
userRepo := repository.NewUserRepository(dbi.DB)
|
||||
tokenRepo := repository.NewTokenRepository(dbi.DB)
|
||||
spaceRepo := repository.NewSpaceRepository(dbi.DB)
|
||||
accountRepo := repository.NewAccountRepository(dbi.DB)
|
||||
spaceSvc := NewSpaceService(spaceRepo)
|
||||
accountSvc := NewAccountService(accountRepo)
|
||||
emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
|
||||
return NewAuthService(
|
||||
emailSvc,
|
||||
userRepo,
|
||||
tokenRepo,
|
||||
spaceSvc,
|
||||
accountSvc,
|
||||
cfg.JWTSecret,
|
||||
cfg.JWTExpiry,
|
||||
cfg.TokenMagicLinkExpiry,
|
||||
|
|
@ -179,11 +182,46 @@ func TestAuthService_CompleteOnboarding(t *testing.T) {
|
|||
err := svc.CompleteOnboarding(user.ID, "New Name")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user name was updated
|
||||
// User name is updated
|
||||
userRepo := repository.NewUserRepository(dbi.DB)
|
||||
updated, err := userRepo.ByID(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, updated.Name)
|
||||
require.NotNil(t, updated.Name)
|
||||
assert.Equal(t, "New Name", *updated.Name)
|
||||
|
||||
// A space named "<name>'s Space" was provisioned
|
||||
spaceRepo := repository.NewSpaceRepository(dbi.DB)
|
||||
spaces, err := spaceRepo.ByUserID(user.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, spaces, 1)
|
||||
assert.Equal(t, "New Name's Space", spaces[0].Name)
|
||||
|
||||
// With a default account inside it
|
||||
accountRepo := repository.NewAccountRepository(dbi.DB)
|
||||
accounts, err := accountRepo.BySpaceID(spaces[0].ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accounts, 1)
|
||||
assert.Equal(t, DefaultAccountName, accounts[0].Name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService_CompleteOnboarding_Idempotent(t *testing.T) {
|
||||
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
|
||||
svc := newTestAuthService(dbi)
|
||||
|
||||
user := testutil.CreateTestUser(t, dbi.DB, "idempotent@example.com", nil)
|
||||
|
||||
require.NoError(t, svc.CompleteOnboarding(user.ID, "Repeat User"))
|
||||
require.NoError(t, svc.CompleteOnboarding(user.ID, "Repeat User"))
|
||||
|
||||
spaceRepo := repository.NewSpaceRepository(dbi.DB)
|
||||
spaces, err := spaceRepo.ByUserID(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, spaces, 1, "second onboarding call must not duplicate the space")
|
||||
|
||||
accountRepo := repository.NewAccountRepository(dbi.DB)
|
||||
accounts, err := accountRepo.BySpaceID(spaces[0].ID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accounts, 1, "second onboarding call must not duplicate the default account")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import (
|
|||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const PersonalSpaceName = "Personal Space"
|
||||
|
||||
type SpaceService struct {
|
||||
spaceRepo repository.SpaceRepository
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue