This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
+162 -18
View File
@@ -52,12 +52,15 @@ func (s *Service) Bootstrap(ctx context.Context, principal domain.Principal) (do
}
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,
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,
@@ -79,6 +82,21 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
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:
@@ -91,18 +109,43 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
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,
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
@@ -113,12 +156,20 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
}
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,
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,
@@ -127,3 +178,96 @@ func (s *Service) Onboard(ctx context.Context, principal domain.Principal, reque
},
}, 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 ""
}