Files
Bookra/apps/backend/internal/tenancy/service.go
T
Tomas Dvorak cf3315e8fc
CI / Frontend (push) Successful in 11m7s
CI / Go - apps/auth-service (push) Failing after 8s
CI / Go - apps/backend (push) Failing after 2s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped
cleanup
2026-05-05 09:48:15 +02:00

264 lines
8.4 KiB
Go

package tenancy
import (
"context"
"errors"
"regexp"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/shared"
"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,
TenantSlug: membership.Tenant.Slug,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
OnboardingCompleted: true,
Brand: s.brandProfile(ctx, membership.Tenant),
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)
locationName := strings.TrimSpace(request.LocationName)
brand := request.Brand
if strings.TrimSpace(brand.Name) == "" {
brand.Name = name
}
defaults := request.BookingDefaults
if strings.TrimSpace(defaults.ServiceName) == "" {
defaults.ServiceName = "First appointment"
}
if defaults.DurationMinutes == 0 {
defaults.DurationMinutes = 60
}
if defaults.CancelWindowHours == 0 {
defaults.CancelWindowHours = 24
}
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
case len(locationName) > 120:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.DurationMinutes < 15 || defaults.DurationMinutes > 480:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.BufferBeforeMinutes < 0 || defaults.BufferBeforeMinutes > 180:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.BufferAfterMinutes < 0 || defaults.BufferAfterMinutes > 180:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
case defaults.CancelWindowHours < 1 || defaults.CancelWindowHours > 720:
return domain.TenantBootstrap{}, ErrInvalidOnboarding
}
if _, err := time.LoadLocation(timezone); err != nil {
return domain.TenantBootstrap{}, ErrInvalidOnboarding
}
if err := validateAvailabilityBlocks(request.AvailabilityBlocks); err != nil {
return domain.TenantBootstrap{}, err
}
membership, err := s.repo.CreateTenantForUser(ctx, db.CreateTenantForUserParams{
Subject: principal.Subject,
Name: name,
Slug: slug,
Preset: preset,
Locale: locale,
Timezone: timezone,
BrandName: strings.TrimSpace(brand.Name),
SiteURL: strings.TrimSpace(brand.SiteURL),
LogoURL: strings.TrimSpace(brand.LogoURL),
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
LocationName: locationName,
ServiceName: strings.TrimSpace(defaults.ServiceName),
DurationMinutes: defaults.DurationMinutes,
BufferBeforeMinutes: defaults.BufferBeforeMinutes,
BufferAfterMinutes: defaults.BufferAfterMinutes,
CancelWindowHours: defaults.CancelWindowHours,
AvailabilityBlocks: toAvailabilityBlocks(request.AvailabilityBlocks),
TeamInvites: toTeamInvites(request.TeamInvites),
})
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,
TenantSlug: membership.Tenant.Slug,
Preset: membership.Tenant.Preset,
Locale: membership.Tenant.Locale,
Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
OnboardingCompleted: true,
Brand: domain.BrandProfile{
Name: strings.TrimSpace(brand.Name),
SiteURL: strings.TrimSpace(brand.SiteURL),
LogoURL: strings.TrimSpace(brand.LogoURL),
PrimaryColor: strings.TrimSpace(brand.PrimaryColor),
},
CurrentUser: domain.Principal{
Subject: principal.Subject,
Email: principal.Email,
Name: principal.Name,
Role: membership.Role,
},
}, nil
}
func (s *Service) brandProfile(ctx context.Context, tenant db.TenantRecord) domain.BrandProfile {
brand, err := s.repo.GetBrandProfile(ctx, tenant.ID)
if err != nil {
return domain.BrandProfile{Name: tenant.Name}
}
return domain.BrandProfile{
Name: firstNonEmpty(brand.Name, tenant.Name),
SiteURL: brand.SiteURL,
LogoURL: brand.LogoURL,
PrimaryColor: brand.PrimaryColor,
}
}
func validateAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) error {
for _, block := range blocks {
if block.DayOfWeek < 0 || block.DayOfWeek > 6 {
return ErrInvalidOnboarding
}
starts, err := time.Parse("15:04", block.StartsLocal)
if err != nil {
starts, err = time.Parse("15:04:05", block.StartsLocal)
}
if err != nil {
return ErrInvalidOnboarding
}
ends, err := time.Parse("15:04", block.EndsLocal)
if err != nil {
ends, err = time.Parse("15:04:05", block.EndsLocal)
}
if err != nil || !ends.After(starts) {
return ErrInvalidOnboarding
}
}
return nil
}
func toAvailabilityBlocks(blocks []domain.AvailabilityBlockRequest) []db.AvailabilityBlockRecord {
records := make([]db.AvailabilityBlockRecord, 0, len(blocks))
for _, block := range blocks {
records = append(records, db.AvailabilityBlockRecord{
DayOfWeek: block.DayOfWeek,
StartsLocal: normalizeClock(block.StartsLocal),
EndsLocal: normalizeClock(block.EndsLocal),
Busy: block.Busy,
})
}
return records
}
func toTeamInvites(invites []domain.TeamInviteRequest) []db.TeamInviteRecord {
records := make([]db.TeamInviteRecord, 0, len(invites))
for _, invite := range invites {
email := strings.TrimSpace(strings.ToLower(invite.Email))
if email == "" {
continue
}
role := strings.TrimSpace(invite.Role)
if role == "" {
role = "staff"
}
records = append(records, db.TeamInviteRecord{Email: email, Role: role})
}
return records
}
func normalizeClock(value string) string {
value = strings.TrimSpace(value)
if len(value) == len("15:04") {
return value + ":00"
}
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}