refactor(db): replace sqlc with handwritten pgx repos

- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:21:34 +02:00
1 parent 2039669e2c
commit f2f7439050
93 files changed
+6987 -69060

No files matched your search

+79 -13
View File
@@ -126,7 +126,7 @@ func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmail
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeConfirmation
msg := RenderEmailMessage(data)
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
@@ -137,7 +137,7 @@ func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailDa
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeReschedule
msg := RenderEmailMessage(data)
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
@@ -148,11 +148,73 @@ func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmail
data.BusinessEmail = s.cfg.EmailFrom
}
data.Type = EmailTypeCancellation
msg := RenderEmailMessage(data)
msg := s.renderWithOverride(ctx, data)
_, err := s.emailProvider.Send(ctx, msg)
return err
}
// renderWithOverride renders the default message and, when the tenant has an
// enabled custom template for this email type, swaps in the stored subject and
// body with {{variable}} substitution.
func (s *Service) renderWithOverride(ctx context.Context, data BookingEmailData) EmailMessage {
msg := RenderEmailMessage(data)
if s.repo == nil || data.TenantID == "" {
return msg
}
tpl, err := s.repo.GetEmailTemplate(ctx, data.TenantID, string(data.Type))
if err != nil || !tpl.IsEnabled {
return msg
}
vars := map[string]string{
"customerName": data.CustomerName,
"customerEmail": data.CustomerEmail,
"service": data.Service,
"location": data.Location,
"reference": data.Reference,
"startsAt": formatLocalizedDateTime(data.StartsAt, data.Timezone, data.Locale),
"tenantName": data.TenantName,
"manageUrl": data.ManagementURL,
}
if tpl.Subject != "" {
msg.Subject = substituteTemplateVars(tpl.Subject, vars)
}
if tpl.BodyText != "" {
msg.Text = substituteTemplateVars(tpl.BodyText, vars)
if tpl.BodyHTML == "" {
msg.HTML = wrapTextAsHTML(msg.Text, data.BrandColor, data.TenantName, data.ManagementURL)
}
}
if tpl.BodyHTML != "" {
msg.HTML = substituteTemplateVars(tpl.BodyHTML, vars)
}
return msg
}
func substituteTemplateVars(body string, vars map[string]string) string {
for key, value := range vars {
body = strings.ReplaceAll(body, "{{"+key+"}}", value)
}
return body
}
func wrapTextAsHTML(text, brandColor, tenantName, manageURL string) string {
if brandColor == "" {
brandColor = "#c25e3a"
}
html := "<html><body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>"
html += "<div style='max-width: 600px; margin: 0 auto; padding: 20px;'>"
html += fmt.Sprintf("<h2 style='color: %s; margin-bottom: 20px;'>%s</h2>", brandColor, tenantName)
for _, p := range splitParagraphs(text) {
if len(p) > 0 {
html += fmt.Sprintf("<p style='margin-bottom: 10px;'>%s</p>", p)
}
}
if manageURL != "" {
html += fmt.Sprintf("<div style='margin-top: 30px;'><a href='%s' style='display: inline-block; background: %s; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px;'>Manage Booking</a></div>", manageURL, brandColor)
}
return html + "</div></body></html>"
}
// SendBusinessNotification sends a notification to the business about a new booking
func (s *Service) SendBusinessNotification(ctx context.Context, businessEmail string, data BookingEmailData) error {
if businessEmail == "" {
@@ -311,22 +373,24 @@ func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locatio
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{
Type: EmailTypeUsageWarning,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
AdminEmail: ownerEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
LocationCount: locationCount,
LocationLimit: locationLimit,
UsagePercent: usagePercent,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)
@@ -348,19 +412,21 @@ func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, day
return fmt.Errorf("failed to get tenant: %w", err)
}
// Use a placeholder admin email - in production, would get from tenant owner
adminEmail := "admin@" + tenant.Slug + ".bookra.eu"
ownerEmail, err := s.repo.GetTenantOwnerEmail(ctx, tenantID)
if err != nil || ownerEmail == "" {
return nil
}
emailData := UsageNotificationData{
Type: EmailTypeTrialEnding,
TenantName: tenant.Name,
TenantSlug: tenant.Slug,
BusinessEmail: s.cfg.EmailFrom,
AdminEmail: adminEmail,
AdminEmail: ownerEmail,
Locale: tenant.Locale,
PlanCode: tenant.PlanCode,
UpgradeURL: "https://bookra.eu/pricing",
DashboardURL: "https://bookra.eu/dashboard",
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
}
msg := RenderUsageNotificationEmail(emailData)