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) GetTenantMembership(ctx context.Context, principal domain.Principal) (db.TenantMembershipRecord, error) { return s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) } func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (domain.TenantBootstrap, error) { membership, err := s.repo.GetTenantMembershipBySubject(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.GetTenantMembershipBySubject(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 } // UpdateBrand persists brand profile fields for the caller's tenant. func (s *Service) UpdateBrand(ctx context.Context, principal domain.Principal, req domain.UpdateBrandRequest) (domain.BrandProfile, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.BrandProfile{}, err } params := db.UpdateBrandProfileParams{ Name: optionalTrimmed(req.Name), SiteURL: optionalTrimmed(req.SiteURL), LogoURL: optionalTrimmed(req.LogoURL), PrimaryColor: optionalTrimmed(req.PrimaryColor), } if _, err := s.repo.UpdateBrandProfile(ctx, membership.Tenant.ID, params); err != nil { return domain.BrandProfile{}, err } return s.brandProfile(ctx, membership.Tenant), nil } func optionalTrimmed(value string) *string { trimmed := strings.TrimSpace(value) if trimmed == "" { return nil } return &trimmed } var emailTemplateTypes = map[string]struct{}{ "confirmation": {}, "reminder": {}, "reschedule": {}, "cancellation": {}, } // ListEmailTemplates returns all custom templates stored for the caller's tenant. func (s *Service) ListEmailTemplates(ctx context.Context, principal domain.Principal) ([]domain.EmailTemplate, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return nil, err } records, err := s.repo.ListEmailTemplates(ctx, membership.Tenant.ID) if err != nil { return nil, err } out := make([]domain.EmailTemplate, len(records)) for i, rec := range records { out[i] = emailTemplateToDomain(rec) } return out, nil } // UpdateEmailTemplate stores a subject/body override for a booking email type. // Supported placeholders: {{customerName}}, {{service}}, {{location}}, // {{reference}}, {{startsAt}}, {{tenantName}}, {{manageUrl}}. func (s *Service) UpdateEmailTemplate(ctx context.Context, principal domain.Principal, req domain.UpdateEmailTemplateRequest) (domain.EmailTemplate, error) { membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject) if err != nil { return domain.EmailTemplate{}, err } templateType := strings.ToLower(strings.TrimSpace(req.Type)) if _, ok := emailTemplateTypes[templateType]; !ok { return domain.EmailTemplate{}, ErrInvalidOnboarding } if len(req.Subject) > 200 || len(req.Body) > 20000 { return domain.EmailTemplate{}, ErrInvalidOnboarding } isEnabled := true if req.IsEnabled != nil { isEnabled = *req.IsEnabled } rec, err := s.repo.UpsertEmailTemplate(ctx, db.EmailTemplateRecord{ TenantID: membership.Tenant.ID, Type: templateType, Subject: strings.TrimSpace(req.Subject), BodyText: strings.TrimSpace(req.Body), IsEnabled: isEnabled, }) if err != nil { return domain.EmailTemplate{}, err } return emailTemplateToDomain(rec), nil } func emailTemplateToDomain(rec db.EmailTemplateRecord) domain.EmailTemplate { return domain.EmailTemplate{ ID: rec.ID, TenantID: rec.TenantID, Type: rec.Type, Subject: rec.Subject, BodyHTML: rec.BodyHTML, BodyText: rec.BodyText, IsEnabled: rec.IsEnabled, } } 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 "" }