From d413193366a18809d48e35e4ae981aaea9ef73a3 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 11 Apr 2026 16:18:46 +0000 Subject: [PATCH] chore: overhaul onboarding steps --- internal/app/app.go | 33 ++++++----- internal/handler/auth.go | 63 +++++--------------- internal/handler/auth_test.go | 63 +++++++++++++------- internal/handler/settings_test.go | 4 +- internal/repository/account.go | 59 +++++++++++++++++++ internal/repository/account_test.go | 77 +++++++++++++++++++++++++ internal/routes/routes_test.go | 19 +++--- internal/service/account.go | 58 +++++++++++++++++++ internal/service/auth.go | 59 +++++++++++++------ internal/service/auth_test.go | 42 +++++++++++++- internal/service/space.go | 2 - internal/ui/pages/onboarding.templ | 89 ++--------------------------- 12 files changed, 370 insertions(+), 198 deletions(-) create mode 100644 internal/repository/account.go create mode 100644 internal/repository/account_test.go create mode 100644 internal/service/account.go diff --git a/internal/app/app.go b/internal/app/app.go index 021b5f7..6c823bd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,13 +11,14 @@ import ( ) type App struct { - Cfg *config.Config - DB *sqlx.DB - UserService *service.UserService - AuthService *service.AuthService - EmailService *service.EmailService - SpaceService *service.SpaceService - InviteService *service.InviteService + Cfg *config.Config + DB *sqlx.DB + UserService *service.UserService + AuthService *service.AuthService + EmailService *service.EmailService + SpaceService *service.SpaceService + AccountService *service.AccountService + InviteService *service.InviteService } func New(cfg *config.Config) (*App, error) { @@ -37,11 +38,13 @@ func New(cfg *config.Config) (*App, error) { userRepository := repository.NewUserRepository(database) tokenRepository := repository.NewTokenRepository(database) spaceRepository := repository.NewSpaceRepository(database) + accountRepository := repository.NewAccountRepository(database) invitationRepository := repository.NewInvitationRepository(database) // Services userService := service.NewUserService(userRepository) spaceService := service.NewSpaceService(spaceRepository) + accountService := service.NewAccountService(accountRepository) emailService := service.NewEmailService( emailClient, cfg.MailerEmailFrom, @@ -54,6 +57,7 @@ func New(cfg *config.Config) (*App, error) { userRepository, tokenRepository, spaceService, + accountService, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, @@ -62,13 +66,14 @@ func New(cfg *config.Config) (*App, error) { inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) return &App{ - Cfg: cfg, - DB: database, - UserService: userService, - AuthService: authService, - EmailService: emailService, - SpaceService: spaceService, - InviteService: inviteService, + Cfg: cfg, + DB: database, + UserService: userService, + AuthService: authService, + EmailService: emailService, + SpaceService: spaceService, + AccountService: accountService, + InviteService: inviteService, }, nil } diff --git a/internal/handler/auth.go b/internal/handler/auth.go index de1ecad..4c1e342 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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) { - step := r.URL.Query().Get("step") - switch step { - case "2": + if r.URL.Query().Get("step") == "name" { ui.Render(w, r, pages.OnboardingName("")) - default: - ui.Render(w, r, pages.OnboardingWelcome()) + return } + ui.Render(w, r, pages.OnboardingWelcome()) } 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 } - step := r.FormValue("step") - - switch step { - case "2": - 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()) + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + ui.Render(w, r, pages.OnboardingName("Please enter your name")) + return } + + 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) { diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go index 51d09f5..257dd1a 100644 --- a/internal/handler/auth_test.go +++ b/internal/handler/auth_test.go @@ -19,10 +19,12 @@ func newTestAuthHandler(dbi testutil.DBInfo) *authHandler { userRepo := repository.NewUserRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB) + accountRepo := repository.NewAccountRepository(dbi.DB) inviteRepo := repository.NewInvitationRepository(dbi.DB) spaceSvc := service.NewSpaceService(spaceRepo) + accountSvc := service.NewAccountService(accountRepo) 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) 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) { 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": {"2"}, "name": {"John"}, }) w := httptest.NewRecorder() 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, "/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) }) } diff --git a/internal/handler/settings_test.go b/internal/handler/settings_test.go index b0c6ac3..e72c49a 100644 --- a/internal/handler/settings_test.go +++ b/internal/handler/settings_test.go @@ -17,9 +17,11 @@ func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.Aut userRepo := repository.NewUserRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB) + accountRepo := repository.NewAccountRepository(dbi.DB) spaceSvc := service.NewSpaceService(spaceRepo) + accountSvc := service.NewAccountService(accountRepo) 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) return NewSettingsHandler(authSvc, userSvc), authSvc } diff --git a/internal/repository/account.go b/internal/repository/account.go new file mode 100644 index 0000000..70354bf --- /dev/null +++ b/internal/repository/account.go @@ -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 +} diff --git a/internal/repository/account_test.go b/internal/repository/account_test.go new file mode 100644 index 0000000..8fa2522 --- /dev/null +++ b/internal/repository/account_test.go @@ -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) + }) +} diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index ec9242c..740ddf4 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -21,22 +21,25 @@ func newTestApp(dbi testutil.DBInfo) *app.App { userRepo := repository.NewUserRepository(dbi.DB) tokenRepo := repository.NewTokenRepository(dbi.DB) spaceRepo := repository.NewSpaceRepository(dbi.DB) + accountRepo := repository.NewAccountRepository(dbi.DB) inviteRepo := repository.NewInvitationRepository(dbi.DB) spaceSvc := service.NewSpaceService(spaceRepo) + accountSvc := service.NewAccountService(accountRepo) 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) inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc) return &app.App{ - Cfg: cfg, - DB: dbi.DB, - UserService: userSvc, - AuthService: authSvc, - EmailService: emailSvc, - SpaceService: spaceSvc, - InviteService: inviteSvc, + Cfg: cfg, + DB: dbi.DB, + UserService: userSvc, + AuthService: authSvc, + EmailService: emailSvc, + SpaceService: spaceSvc, + AccountService: accountSvc, + InviteService: inviteSvc, } } diff --git a/internal/service/account.go b/internal/service/account.go new file mode 100644 index 0000000..0331bf1 --- /dev/null +++ b/internal/service/account.go @@ -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 +} diff --git a/internal/service/auth.go b/internal/service/auth.go index 4fc789f..a5d6ff6 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -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 } diff --git a/internal/service/auth_test.go b/internal/service/auth_test.go index efa7f90..d34fdd6 100644 --- a/internal/service/auth_test.go +++ b/internal/service/auth_test.go @@ -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 "'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") }) } diff --git a/internal/service/space.go b/internal/service/space.go index 8e2d48b..ea2763e 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -9,8 +9,6 @@ import ( "github.com/google/uuid" ) -const PersonalSpaceName = "Personal Space" - type SpaceService struct { spaceRepo repository.SpaceRepository } diff --git a/internal/ui/pages/onboarding.templ b/internal/ui/pages/onboarding.templ index 08d4c09..4040eca 100644 --- a/internal/ui/pages/onboarding.templ +++ b/internal/ui/pages/onboarding.templ @@ -14,7 +14,7 @@ import ( templ stepIndicator(current int) {
- for i := 1; i <= 3; i++ { + for i := 1; i <= 2; i++ { if i == current {
} else if i < current { @@ -23,7 +23,7 @@ templ stepIndicator(current int) {
} } - { fmt.Sprintf("%d/3", current) } + { fmt.Sprintf("%d/2", current) }
} @@ -56,7 +56,7 @@ templ OnboardingWelcome() { Let's get you set up in just a couple of steps.

@button.Button(button.Props{ - Href: "/auth/onboarding?step=2", + Href: "/auth/onboarding?step=name", FullWidth: true, }) { Get Started @@ -107,7 +107,6 @@ templ OnboardingName(errorMsg string) {
@csrf.Token() - @form.Item() { @label.Label(label.Props{ For: "name", @@ -140,7 +139,7 @@ templ OnboardingName(errorMsg string) { @button.Submit(button.Props{ Class: "grow", }) { - Continue + Finish @icon.ArrowRight() } @@ -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), - }) { -
-
-
-
- @button.Button(button.Props{ - Variant: button.VariantSecondary, - Size: button.SizeLg, - Href: "/", - }) { - @icon.Layers() - { cfg.AppName } - } -
- @stepIndicator(3) -

Create your space

-

- A space is where you organize expenses and lists. You can invite others later. -

-
- - @csrf.Token() - - - @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 } - } - } - } -
- @button.Button(button.Props{ - Variant: button.VariantOutline, - Href: "/auth/onboarding?step=2", - }) { - @icon.ArrowLeft() - Back - } - @button.Submit(button.Props{ - Class: "grow", - }) { - Create Space - } -
- -
- @csrf.Token() - Not you? - @button.Submit(button.Props{ - Variant: button.VariantLink, - Class: "p-0 h-auto text-sm", - }) { - Sign out - } -
-
-
- } -}