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 }