chore: overhaul onboarding steps

This commit is contained in:
juancwu 2026-04-11 16:18:46 +00:00
commit d413193366
12 changed files with 358 additions and 186 deletions

View 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
}

View file

@ -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
}

View file

@ -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")
})
}

View file

@ -9,8 +9,6 @@ import (
"github.com/google/uuid"
)
const PersonalSpaceName = "Personal Space"
type SpaceService struct {
spaceRepo repository.SpaceRepository
}