Files
Bookra/apps/backend/internal/tenancy/service.go
T
Tomas Dvorak 035ac8ddb5 first commit
2026-04-10 12:01:36 +02:00

130 lines
3.8 KiB
Go

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
}