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

@ -11,13 +11,14 @@ import (
) )
type App struct { type App struct {
Cfg *config.Config Cfg *config.Config
DB *sqlx.DB DB *sqlx.DB
UserService *service.UserService UserService *service.UserService
AuthService *service.AuthService AuthService *service.AuthService
EmailService *service.EmailService EmailService *service.EmailService
SpaceService *service.SpaceService SpaceService *service.SpaceService
InviteService *service.InviteService AccountService *service.AccountService
InviteService *service.InviteService
} }
func New(cfg *config.Config) (*App, error) { func New(cfg *config.Config) (*App, error) {
@ -37,11 +38,13 @@ func New(cfg *config.Config) (*App, error) {
userRepository := repository.NewUserRepository(database) userRepository := repository.NewUserRepository(database)
tokenRepository := repository.NewTokenRepository(database) tokenRepository := repository.NewTokenRepository(database)
spaceRepository := repository.NewSpaceRepository(database) spaceRepository := repository.NewSpaceRepository(database)
accountRepository := repository.NewAccountRepository(database)
invitationRepository := repository.NewInvitationRepository(database) invitationRepository := repository.NewInvitationRepository(database)
// Services // Services
userService := service.NewUserService(userRepository) userService := service.NewUserService(userRepository)
spaceService := service.NewSpaceService(spaceRepository) spaceService := service.NewSpaceService(spaceRepository)
accountService := service.NewAccountService(accountRepository)
emailService := service.NewEmailService( emailService := service.NewEmailService(
emailClient, emailClient,
cfg.MailerEmailFrom, cfg.MailerEmailFrom,
@ -54,6 +57,7 @@ func New(cfg *config.Config) (*App, error) {
userRepository, userRepository,
tokenRepository, tokenRepository,
spaceService, spaceService,
accountService,
cfg.JWTSecret, cfg.JWTSecret,
cfg.JWTExpiry, cfg.JWTExpiry,
cfg.TokenMagicLinkExpiry, cfg.TokenMagicLinkExpiry,
@ -62,13 +66,14 @@ func New(cfg *config.Config) (*App, error) {
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
return &App{ return &App{
Cfg: cfg, Cfg: cfg,
DB: database, DB: database,
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
EmailService: emailService, EmailService: emailService,
SpaceService: spaceService, SpaceService: spaceService,
InviteService: inviteService, AccountService: accountService,
InviteService: inviteService,
}, nil }, nil
} }

View file

@ -168,13 +168,11 @@ func (h *authHandler) completeLogin(w http.ResponseWriter, r *http.Request, user
} }
func (h *authHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) { func (h *authHandler) OnboardingPage(w http.ResponseWriter, r *http.Request) {
step := r.URL.Query().Get("step") if r.URL.Query().Get("step") == "name" {
switch step {
case "2":
ui.Render(w, r, pages.OnboardingName("")) ui.Render(w, r, pages.OnboardingName(""))
default: return
ui.Render(w, r, pages.OnboardingWelcome())
} }
ui.Render(w, r, pages.OnboardingWelcome())
} }
func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) { func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request) {
@ -184,50 +182,19 @@ func (h *authHandler) CompleteOnboarding(w http.ResponseWriter, r *http.Request)
return return
} }
step := r.FormValue("step") name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
switch step { ui.Render(w, r, pages.OnboardingName("Please enter your name"))
case "2": return
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
ui.Render(w, r, pages.OnboardingName("Please enter your name"))
return
}
ui.Render(w, r, pages.OnboardingSpace(name, ""))
case "3":
name := strings.TrimSpace(r.FormValue("name"))
spaceName := strings.TrimSpace(r.FormValue("space_name"))
if name == "" {
ui.Render(w, r, pages.OnboardingName("Please enter your name"))
return
}
if spaceName == "" {
ui.Render(w, r, pages.OnboardingSpace(name, "Please enter a space name"))
return
}
err := h.authService.CompleteOnboarding(user.ID, name)
if err != nil {
slog.Error("onboarding failed", "error", err, "user_id", user.ID)
ui.Render(w, r, pages.OnboardingName("Please enter a valid name"))
return
}
_, err = h.spaceService.CreateSpace(spaceName, user.ID)
if err != nil {
slog.Error("failed to create space during onboarding", "error", err, "user_id", user.ID)
ui.Render(w, r, pages.OnboardingSpace(name, "Failed to create space. Please try again."))
return
}
slog.Info("onboarding completed", "user_id", user.ID, "name", name, "space", spaceName)
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
default:
ui.Render(w, r, pages.OnboardingWelcome())
} }
if err := h.authService.CompleteOnboarding(user.ID, name); err != nil {
slog.Error("onboarding failed", "error", err, "user_id", user.ID)
ui.Render(w, r, pages.OnboardingName("We couldn't finish setting you up. Please try again."))
return
}
http.Redirect(w, r, "/app/dashboard", http.StatusSeeOther)
} }
func (h *authHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { func (h *authHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {

View file

@ -19,10 +19,12 @@ func newTestAuthHandler(dbi testutil.DBInfo) *authHandler {
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB)
accountRepo := repository.NewAccountRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB) inviteRepo := repository.NewInvitationRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo) spaceSvc := service.NewSpaceService(spaceRepo)
accountSvc := service.NewAccountService(accountRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc) inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
return NewAuthHandler(authSvc, inviteSvc, spaceSvc) return NewAuthHandler(authSvc, inviteSvc, spaceSvc)
} }
@ -95,40 +97,57 @@ func TestAuthHandler_Logout(t *testing.T) {
}) })
} }
func TestAuthHandler_CompleteOnboarding_Step2(t *testing.T) { func TestAuthHandler_CompleteOnboarding_Success(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi) h := newTestAuthHandler(dbi)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, url.Values{ req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, url.Values{
"step": {"2"},
"name": {"John"}, "name": {"John"},
}) })
w := httptest.NewRecorder() w := httptest.NewRecorder()
h.CompleteOnboarding(w, req) h.CompleteOnboarding(w, req)
assert.Equal(t, http.StatusOK, w.Code)
})
}
func TestAuthHandler_CompleteOnboarding_Step3(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, url.Values{
"step": {"3"},
"name": {"John"},
"space_name": {"My Space"},
})
w := httptest.NewRecorder()
h.CompleteOnboarding(w, req)
assert.Equal(t, http.StatusSeeOther, w.Code) assert.Equal(t, http.StatusSeeOther, w.Code)
assert.Equal(t, "/app/dashboard", w.Header().Get("Location")) assert.Equal(t, "/app/dashboard", w.Header().Get("Location"))
// Space "John's Space" with a default account should now exist
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaces, err := spaceRepo.ByUserID(user.ID)
assert.NoError(t, err)
assert.Len(t, spaces, 1)
assert.Equal(t, "John's Space", spaces[0].Name)
accountRepo := repository.NewAccountRepository(dbi.DB)
accounts, err := accountRepo.BySpaceID(spaces[0].ID)
assert.NoError(t, err)
assert.Len(t, accounts, 1)
assert.Equal(t, service.DefaultAccountName, accounts[0].Name)
})
}
func TestAuthHandler_CompleteOnboarding_EmptyName(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
h := newTestAuthHandler(dbi)
user := testutil.CreateTestUser(t, dbi.DB, "empty@example.com", nil)
req := testutil.NewAuthenticatedRequest(t, http.MethodPost, "/auth/onboarding", user, url.Values{
"name": {" "},
})
w := httptest.NewRecorder()
h.CompleteOnboarding(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "Please enter your name")
// No space should have been created
spaceRepo := repository.NewSpaceRepository(dbi.DB)
spaces, err := spaceRepo.ByUserID(user.ID)
assert.NoError(t, err)
assert.Empty(t, spaces)
}) })
} }

View file

@ -17,9 +17,11 @@ func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.Aut
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB)
accountRepo := repository.NewAccountRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo) spaceSvc := service.NewSpaceService(spaceRepo)
accountSvc := service.NewAccountService(accountRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
userSvc := service.NewUserService(userRepo) userSvc := service.NewUserService(userRepo)
return NewSettingsHandler(authSvc, userSvc), authSvc return NewSettingsHandler(authSvc, userSvc), authSvc
} }

View file

@ -0,0 +1,59 @@
package repository
import (
"database/sql"
"errors"
"git.juancwu.dev/juancwu/budgit/internal/model"
"github.com/jmoiron/sqlx"
)
var ErrAccountNotFound = errors.New("account not found")
type AccountRepository interface {
Create(account *model.Account) error
ByID(id string) (*model.Account, error)
BySpaceID(spaceID string) ([]*model.Account, error)
Delete(id string) error
}
type accountRepository struct {
db *sqlx.DB
}
func NewAccountRepository(db *sqlx.DB) AccountRepository {
return &accountRepository{db: db}
}
func (r *accountRepository) Create(account *model.Account) error {
query := `INSERT INTO accounts (id, name, space_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5);`
_, err := r.db.Exec(query, account.ID, account.Name, account.SpaceID, account.CreatedAt, account.UpdatedAt)
return err
}
func (r *accountRepository) ByID(id string) (*model.Account, error) {
account := &model.Account{}
query := `SELECT * FROM accounts WHERE id = $1;`
err := r.db.Get(account, query, id)
if err == sql.ErrNoRows {
return nil, ErrAccountNotFound
}
return account, err
}
func (r *accountRepository) BySpaceID(spaceID string) ([]*model.Account, error) {
var accounts []*model.Account
query := `SELECT * FROM accounts WHERE space_id = $1 ORDER BY created_at ASC;`
err := r.db.Select(&accounts, query, spaceID)
if err != nil {
return nil, err
}
return accounts, nil
}
func (r *accountRepository) Delete(id string) error {
query := `DELETE FROM accounts WHERE id = $1;`
_, err := r.db.Exec(query, id)
return err
}

View file

@ -0,0 +1,77 @@
package repository
import (
"testing"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAccountRepository_CreateAndRead(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "account-create@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Space With Account")
now := time.Now()
account := &model.Account{
ID: uuid.NewString(),
Name: "Money Account",
SpaceID: space.ID,
CreatedAt: now,
UpdatedAt: now,
}
err := repo.Create(account)
require.NoError(t, err)
fetched, err := repo.ByID(account.ID)
require.NoError(t, err)
assert.Equal(t, "Money Account", fetched.Name)
assert.Equal(t, space.ID, fetched.SpaceID)
accounts, err := repo.BySpaceID(space.ID)
require.NoError(t, err)
require.Len(t, accounts, 1)
assert.Equal(t, account.ID, accounts[0].ID)
})
}
func TestAccountRepository_ByID_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewAccountRepository(dbi.DB)
_, err := repo.ByID(uuid.NewString())
assert.ErrorIs(t, err, ErrAccountNotFound)
})
}
func TestAccountRepository_Delete(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
repo := NewAccountRepository(dbi.DB)
user := testutil.CreateTestUser(t, dbi.DB, "account-delete@example.com", nil)
space := testutil.CreateTestSpace(t, dbi.DB, user.ID, "Delete Space")
now := time.Now()
account := &model.Account{
ID: uuid.NewString(),
Name: "To Delete",
SpaceID: space.ID,
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, repo.Create(account))
err := repo.Delete(account.ID)
require.NoError(t, err)
_, err = repo.ByID(account.ID)
assert.ErrorIs(t, err, ErrAccountNotFound)
})
}

View file

@ -21,22 +21,25 @@ func newTestApp(dbi testutil.DBInfo) *app.App {
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB)
accountRepo := repository.NewAccountRepository(dbi.DB)
inviteRepo := repository.NewInvitationRepository(dbi.DB) inviteRepo := repository.NewInvitationRepository(dbi.DB)
spaceSvc := service.NewSpaceService(spaceRepo) spaceSvc := service.NewSpaceService(spaceRepo)
accountSvc := service.NewAccountService(accountRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false)
userSvc := service.NewUserService(userRepo) userSvc := service.NewUserService(userRepo)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc) inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc)
return &app.App{ return &app.App{
Cfg: cfg, Cfg: cfg,
DB: dbi.DB, DB: dbi.DB,
UserService: userSvc, UserService: userSvc,
AuthService: authSvc, AuthService: authSvc,
EmailService: emailSvc, EmailService: emailSvc,
SpaceService: spaceSvc, SpaceService: spaceSvc,
InviteService: inviteSvc, AccountService: accountSvc,
InviteService: inviteSvc,
} }
} }

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 userRepository repository.UserRepository
tokenRepository repository.TokenRepository tokenRepository repository.TokenRepository
spaceService *SpaceService spaceService *SpaceService
accountService *AccountService
jwtSecret string jwtSecret string
jwtExpiry time.Duration jwtExpiry time.Duration
tokenMagicLinkExpiry time.Duration tokenMagicLinkExpiry time.Duration
@ -47,20 +48,22 @@ func NewAuthService(
userRepository repository.UserRepository, userRepository repository.UserRepository,
tokenRepository repository.TokenRepository, tokenRepository repository.TokenRepository,
spaceService *SpaceService, spaceService *SpaceService,
accountService *AccountService,
jwtSecret string, jwtSecret string,
jwtExpiry time.Duration, jwtExpiry time.Duration,
tokenMagicLinkExpiry time.Duration, tokenMagicLinkExpiry time.Duration,
isProduction bool, isProduction bool,
) *AuthService { ) *AuthService {
return &AuthService{ return &AuthService{
emailService: emailService, emailService: emailService,
userRepository: userRepository, userRepository: userRepository,
tokenRepository: tokenRepository, tokenRepository: tokenRepository,
spaceService: spaceService, spaceService: spaceService,
jwtSecret: jwtSecret, accountService: accountService,
jwtExpiry: jwtExpiry, jwtSecret: jwtSecret,
jwtExpiry: jwtExpiry,
tokenMagicLinkExpiry: tokenMagicLinkExpiry, 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 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 { func (s *AuthService) CompleteOnboarding(userID, name string) error {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if err := validation.ValidateName(name); err != nil {
err := validation.ValidateName(name)
if err != nil {
return err return err
} }
@ -345,18 +352,38 @@ func (s *AuthService) CompleteOnboarding(userID, name string) error {
return fmt.Errorf("failed to get user: %w", err) return fmt.Errorf("failed to get user: %w", err)
} }
user.Name = &name existing, err := s.spaceService.GetSpacesForUser(userID)
err = s.userRepository.Update(user)
if err != nil { 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) return fmt.Errorf("failed to update user: %w", err)
} }
err = s.emailService.SendWelcomeEmail(user.Email, name) if err := s.emailService.SendWelcomeEmail(user.Email, name); err != nil {
if err != nil {
slog.Warn("failed to send welcome email", "error", err, "email", user.Email) 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 return nil
} }

View file

@ -16,13 +16,16 @@ func newTestAuthService(dbi testutil.DBInfo) *AuthService {
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
tokenRepo := repository.NewTokenRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB)
spaceRepo := repository.NewSpaceRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB)
accountRepo := repository.NewAccountRepository(dbi.DB)
spaceSvc := NewSpaceService(spaceRepo) spaceSvc := NewSpaceService(spaceRepo)
accountSvc := NewAccountService(accountRepo)
emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) emailSvc := NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
return NewAuthService( return NewAuthService(
emailSvc, emailSvc,
userRepo, userRepo,
tokenRepo, tokenRepo,
spaceSvc, spaceSvc,
accountSvc,
cfg.JWTSecret, cfg.JWTSecret,
cfg.JWTExpiry, cfg.JWTExpiry,
cfg.TokenMagicLinkExpiry, cfg.TokenMagicLinkExpiry,
@ -179,11 +182,46 @@ func TestAuthService_CompleteOnboarding(t *testing.T) {
err := svc.CompleteOnboarding(user.ID, "New Name") err := svc.CompleteOnboarding(user.ID, "New Name")
require.NoError(t, err) require.NoError(t, err)
// Verify user name was updated // User name is updated
userRepo := repository.NewUserRepository(dbi.DB) userRepo := repository.NewUserRepository(dbi.DB)
updated, err := userRepo.ByID(user.ID) updated, err := userRepo.ByID(user.ID)
require.NoError(t, err) require.NoError(t, err)
assert.NotNil(t, updated.Name) require.NotNil(t, updated.Name)
assert.Equal(t, "New Name", *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" "github.com/google/uuid"
) )
const PersonalSpaceName = "Personal Space"
type SpaceService struct { type SpaceService struct {
spaceRepo repository.SpaceRepository spaceRepo repository.SpaceRepository
} }

View file

@ -14,7 +14,7 @@ import (
templ stepIndicator(current int) { templ stepIndicator(current int) {
<div class="flex items-center justify-center gap-2 mb-8"> <div class="flex items-center justify-center gap-2 mb-8">
for i := 1; i <= 3; i++ { for i := 1; i <= 2; i++ {
if i == current { if i == current {
<div class="w-2.5 h-2.5 rounded-full bg-primary"></div> <div class="w-2.5 h-2.5 rounded-full bg-primary"></div>
} else if i < current { } else if i < current {
@ -23,7 +23,7 @@ templ stepIndicator(current int) {
<div class="w-2.5 h-2.5 rounded-full bg-muted"></div> <div class="w-2.5 h-2.5 rounded-full bg-muted"></div>
} }
} }
<span class="text-xs text-muted-foreground ml-2">{ fmt.Sprintf("%d/3", current) }</span> <span class="text-xs text-muted-foreground ml-2">{ fmt.Sprintf("%d/2", current) }</span>
</div> </div>
} }
@ -56,7 +56,7 @@ templ OnboardingWelcome() {
Let's get you set up in just a couple of steps. Let's get you set up in just a couple of steps.
</p> </p>
@button.Button(button.Props{ @button.Button(button.Props{
Href: "/auth/onboarding?step=2", Href: "/auth/onboarding?step=name",
FullWidth: true, FullWidth: true,
}) { }) {
Get Started Get Started
@ -107,7 +107,6 @@ templ OnboardingName(errorMsg string) {
</div> </div>
<form action="/auth/onboarding" method="POST" class="space-y-6"> <form action="/auth/onboarding" method="POST" class="space-y-6">
@csrf.Token() @csrf.Token()
<input type="hidden" name="step" value="2"/>
@form.Item() { @form.Item() {
@label.Label(label.Props{ @label.Label(label.Props{
For: "name", For: "name",
@ -140,7 +139,7 @@ templ OnboardingName(errorMsg string) {
@button.Submit(button.Props{ @button.Submit(button.Props{
Class: "grow", Class: "grow",
}) { }) {
Continue Finish
@icon.ArrowRight() @icon.ArrowRight()
} }
</div> </div>
@ -160,83 +159,3 @@ templ OnboardingName(errorMsg string) {
} }
} }
templ OnboardingSpace(name string, errorMsg string) {
{{ cfg := ctxkeys.Config(ctx) }}
@layouts.Auth(layouts.SEOProps{
Title: "Create Your Space",
Description: "Create your first space",
Path: ctxkeys.URLPath(ctx),
}) {
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="mb-8">
@button.Button(button.Props{
Variant: button.VariantSecondary,
Size: button.SizeLg,
Href: "/",
}) {
@icon.Layers()
{ cfg.AppName }
}
</div>
@stepIndicator(3)
<h2 class="text-3xl font-bold">Create your space</h2>
<p class="text-muted-foreground mt-2">
A space is where you organize expenses and lists. You can invite others later.
</p>
</div>
<form action="/auth/onboarding" method="POST" class="space-y-6">
@csrf.Token()
<input type="hidden" name="step" value="3"/>
<input type="hidden" name="name" value={ name }/>
@form.Item() {
@label.Label(label.Props{
For: "space_name",
Class: "block mb-2",
}) {
Space Name
}
@input.Input(input.Props{
ID: "space_name",
Name: "space_name",
Type: input.TypeText,
Placeholder: "My Household",
HasError: errorMsg != "",
Attributes: templ.Attributes{"autofocus": ""},
})
if errorMsg != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ errorMsg }
}
}
}
<div class="flex gap-3">
@button.Button(button.Props{
Variant: button.VariantOutline,
Href: "/auth/onboarding?step=2",
}) {
@icon.ArrowLeft()
Back
}
@button.Submit(button.Props{
Class: "grow",
}) {
Create Space
}
</div>
</form>
<form action="/auth/logout" method="POST" class="text-center mt-6">
@csrf.Token()
<span class="text-sm text-muted-foreground">Not you? </span>
@button.Submit(button.Props{
Variant: button.VariantLink,
Class: "p-0 h-auto text-sm",
}) {
Sign out
}
</form>
</div>
</div>
}
}