mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
264 lines
8.4 KiB
Go
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 ""
|
|
}
|