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, TenantSlug: membership.Tenant.Slug, Preset: membership.Tenant.Preset, Locale: membership.Tenant.Locale, Timezone: membership.Tenant.Timezone, PlanCode: 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: 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 normalizePlanCode(planCode string) string { switch planCode { case "growth": return "pro" case "multi-location": return "business" default: return planCode } } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }