mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 12:33:00 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package tenancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTenantAlreadyProvisioned = errors.New("tenant already provisioned for user")
|
||||
ErrInvalidOnboarding = errors.New("invalid onboarding request")
|
||||
ErrTenantSlugTaken = errors.New("tenant slug is already in use")
|
||||
)
|
||||
|
||||
var tenantSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
type Service struct {
|
||||
repo db.Repository
|
||||
}
|
||||
|
||||
func NewService(repo db.Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) {
|
||||
membership, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: "",
|
||||
TenantName: "",
|
||||
Preset: "",
|
||||
Locale: "cs",
|
||||
Timezone: "Europe/Prague",
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
Name: principal.Name,
|
||||
Role: principal.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
Name: principal.Name,
|
||||
Role: membership.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Onboard(ctx context.Context, principal domain.Principal, request domain.OnboardTenantRequest) (domain.TenantBootstrap, error) {
|
||||
if _, err := s.repo.GetTenantMembershipByUserID(ctx, principal.Subject); err == nil {
|
||||
return domain.TenantBootstrap{}, ErrTenantAlreadyProvisioned
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(request.Name)
|
||||
slug := strings.TrimSpace(request.Slug)
|
||||
preset := strings.TrimSpace(request.Preset)
|
||||
locale := strings.TrimSpace(request.Locale)
|
||||
timezone := strings.TrimSpace(request.Timezone)
|
||||
|
||||
switch {
|
||||
case len(name) < 2 || len(name) > 80:
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case len(slug) < 3 || len(slug) > 48 || !tenantSlugPattern.MatchString(slug):
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case preset != "salon" && preset != "clinic" && preset != "massage" && preset != "repair" && preset != "studio":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case locale != "cs" && locale != "en":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
case timezone == "":
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
if _, err := time.LoadLocation(timezone); err != nil {
|
||||
return domain.TenantBootstrap{}, ErrInvalidOnboarding
|
||||
}
|
||||
|
||||
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
|
||||
Subject: principal.Subject,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Preset: preset,
|
||||
Locale: locale,
|
||||
Timezone: timezone,
|
||||
})
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return domain.TenantBootstrap{}, ErrTenantSlugTaken
|
||||
}
|
||||
return domain.TenantBootstrap{}, err
|
||||
}
|
||||
|
||||
return domain.TenantBootstrap{
|
||||
TenantID: membership.Tenant.ID,
|
||||
TenantName: membership.Tenant.Name,
|
||||
Preset: membership.Tenant.Preset,
|
||||
Locale: membership.Tenant.Locale,
|
||||
Timezone: membership.Tenant.Timezone,
|
||||
PlanCode: membership.Tenant.PlanCode,
|
||||
CurrentUser: domain.Principal{
|
||||
Subject: principal.Subject,
|
||||
Email: principal.Email,
|
||||
Name: principal.Name,
|
||||
Role: membership.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package tenancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestBootstrapResolvesMembershipAfterIdentitySync(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
if err := repo.EnsureUserIdentity(context.Background(), "neon-user-123", "owner@bookra.dev", "Neon Owner"); err != nil {
|
||||
t.Fatalf("ensure user identity: %v", err)
|
||||
}
|
||||
|
||||
bootstrap, err := service.Bootstrap(context.Background(), domain.Principal{
|
||||
Subject: "neon-user-123",
|
||||
Email: "owner@bookra.dev",
|
||||
Name: "Neon Owner",
|
||||
Role: "authenticated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
if bootstrap.TenantID == "" {
|
||||
t.Fatal("expected tenant id")
|
||||
}
|
||||
if bootstrap.CurrentUser.Role != "owner" {
|
||||
t.Fatalf("expected owner role, got %s", bootstrap.CurrentUser.Role)
|
||||
}
|
||||
if bootstrap.CurrentUser.Name != "Neon Owner" {
|
||||
t.Fatalf("expected synced name, got %s", bootstrap.CurrentUser.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapReturnsShellWhenMembershipMissing(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
bootstrap, err := service.Bootstrap(context.Background(), domain.Principal{
|
||||
Subject: "unassigned-user",
|
||||
Email: "new@bookra.dev",
|
||||
Name: "New User",
|
||||
Role: "authenticated",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bootstrap without membership: %v", err)
|
||||
}
|
||||
|
||||
if bootstrap.TenantID != "" {
|
||||
t.Fatalf("expected empty tenant id, got %s", bootstrap.TenantID)
|
||||
}
|
||||
if bootstrap.CurrentUser.Subject != "unassigned-user" {
|
||||
t.Fatalf("expected subject passthrough, got %s", bootstrap.CurrentUser.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardCreatesTenantForAuthenticatedUser(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
bootstrap, err := service.Onboard(context.Background(), domain.Principal{
|
||||
Subject: "fresh-user",
|
||||
Email: "fresh@bookra.dev",
|
||||
Name: "Fresh User",
|
||||
Role: "authenticated",
|
||||
}, domain.OnboardTenantRequest{
|
||||
Name: "Fresh Studio",
|
||||
Slug: "fresh-studio",
|
||||
Preset: "studio",
|
||||
Locale: "cs",
|
||||
Timezone: "Europe/Prague",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("onboard: %v", err)
|
||||
}
|
||||
if bootstrap.TenantName != "Fresh Studio" {
|
||||
t.Fatalf("expected tenant name, got %s", bootstrap.TenantName)
|
||||
}
|
||||
if bootstrap.CurrentUser.Role != "owner" {
|
||||
t.Fatalf("expected owner role, got %s", bootstrap.CurrentUser.Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardRejectsInvalidSlug(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo)
|
||||
|
||||
_, err := service.Onboard(context.Background(), domain.Principal{
|
||||
Subject: "fresh-user",
|
||||
Email: "fresh@bookra.dev",
|
||||
Name: "Fresh User",
|
||||
Role: "authenticated",
|
||||
}, domain.OnboardTenantRequest{
|
||||
Name: "Fresh Studio",
|
||||
Slug: "bad slug",
|
||||
Preset: "studio",
|
||||
Locale: "cs",
|
||||
Timezone: "Europe/Prague",
|
||||
})
|
||||
if err != ErrInvalidOnboarding {
|
||||
t.Fatalf("expected invalid onboarding, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user