chore: overhaul onboarding steps
This commit is contained in:
parent
acb7f511f9
commit
d413193366
12 changed files with 358 additions and 186 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
internal/repository/account.go
Normal file
59
internal/repository/account.go
Normal 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
|
||||||
|
}
|
||||||
77
internal/repository/account_test.go
Normal file
77
internal/repository/account_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue