mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
The reminder pipeline fired regardless of what happened to the booking after creation: cancelled customers got reminded about bookings that weren't happening, reschedules kept the old reminder time, and waitlisted bookings armed jobs too. Pending jobs are now cancelled on both cancel paths, deleted and re-armed on reschedule, and dispatch joins bookings.status to skip anything not confirmed. Reschedule also gained the same availability-window and blocked-day validation as booking creation, closing a 3am-reschedule hole via the manage URL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
444 lines
13 KiB
Go
444 lines
13 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
|
|
"bookra/apps/backend/internal/config"
|
|
"bookra/apps/backend/internal/db"
|
|
"bookra/apps/backend/internal/domain"
|
|
)
|
|
|
|
var ErrUnsupportedChannel = errors.New("unsupported notification channel")
|
|
|
|
type DeliveryReceipt struct {
|
|
Provider string
|
|
ExternalID string
|
|
}
|
|
|
|
type EmailMessage struct {
|
|
From string
|
|
To string
|
|
Subject string
|
|
Text string
|
|
HTML string
|
|
}
|
|
|
|
type EmailProvider interface {
|
|
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
|
|
}
|
|
|
|
type Service struct {
|
|
cfg config.Config
|
|
repo db.Repository
|
|
emailProvider EmailProvider
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewService(cfg config.Config, repo db.Repository) *Service {
|
|
emailProvider := EmailProvider(noopEmailProvider{})
|
|
if cfg.SMTPHost != "" {
|
|
emailProvider = smtpEmailProvider{cfg: cfg}
|
|
}
|
|
|
|
return &Service{
|
|
cfg: cfg,
|
|
repo: repo,
|
|
emailProvider: emailProvider,
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchReminderJobsResponse, error) {
|
|
if limit <= 0 {
|
|
limit = 25
|
|
}
|
|
|
|
jobs, err := s.repo.ListDueReminderJobs(ctx, s.now(), limit)
|
|
if err != nil {
|
|
return domain.DispatchReminderJobsResponse{}, err
|
|
}
|
|
|
|
response := domain.DispatchReminderJobsResponse{}
|
|
for _, job := range jobs {
|
|
response.ProcessedCount++
|
|
|
|
if job.BookingStatus != "confirmed" {
|
|
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, "skipped", s.now()); err != nil {
|
|
return domain.DispatchReminderJobsResponse{}, err
|
|
}
|
|
response.SkippedCount++
|
|
continue
|
|
}
|
|
|
|
status := "sent"
|
|
provider := "unknown"
|
|
externalID := ""
|
|
errorMessage := ""
|
|
|
|
switch job.Channel {
|
|
case "email":
|
|
receipt, sendErr := s.emailProvider.Send(ctx, renderEmailMessage(s.cfg.EmailFrom, job))
|
|
if sendErr != nil {
|
|
status = "failed"
|
|
errorMessage = sendErr.Error()
|
|
} else {
|
|
provider = receipt.Provider
|
|
externalID = receipt.ExternalID
|
|
}
|
|
default:
|
|
status = "failed"
|
|
errorMessage = ErrUnsupportedChannel.Error()
|
|
}
|
|
|
|
if provider == "unknown" {
|
|
if job.Channel == "email" {
|
|
provider = "noop-email"
|
|
}
|
|
}
|
|
|
|
if err := s.repo.MarkReminderJobDispatched(ctx, job.ID, status, s.now()); err != nil {
|
|
return domain.DispatchReminderJobsResponse{}, err
|
|
}
|
|
if err := s.repo.CreateNotificationDeliveryLog(ctx, db.NotificationDeliveryLogParams{
|
|
TenantID: job.TenantID,
|
|
ReminderJobID: job.ID,
|
|
Channel: job.Channel,
|
|
Provider: provider,
|
|
Recipient: reminderRecipient(job),
|
|
Status: status,
|
|
ExternalID: externalID,
|
|
ErrorMessage: errorMessage,
|
|
}); err != nil {
|
|
return domain.DispatchReminderJobsResponse{}, err
|
|
}
|
|
|
|
if status == "sent" {
|
|
response.SentCount++
|
|
} else {
|
|
response.FailedCount++
|
|
}
|
|
}
|
|
|
|
return response, nil
|
|
}
|
|
|
|
// SendBookingConfirmation sends a booking confirmation email to the customer
|
|
func (s *Service) SendBookingConfirmation(ctx context.Context, data BookingEmailData) error {
|
|
if data.BusinessEmail == "" {
|
|
data.BusinessEmail = s.cfg.EmailFrom
|
|
}
|
|
data.Type = EmailTypeConfirmation
|
|
msg := s.renderWithOverride(ctx, data)
|
|
_, err := s.emailProvider.Send(ctx, msg)
|
|
return err
|
|
}
|
|
|
|
// SendBookingReschedule sends a reschedule notification email to the customer
|
|
func (s *Service) SendBookingReschedule(ctx context.Context, data BookingEmailData) error {
|
|
if data.BusinessEmail == "" {
|
|
data.BusinessEmail = s.cfg.EmailFrom
|
|
}
|
|
data.Type = EmailTypeReschedule
|
|
msg := s.renderWithOverride(ctx, data)
|
|
_, err := s.emailProvider.Send(ctx, msg)
|
|
return err
|
|
}
|
|
|
|
// SendBookingCancellation sends a cancellation notification email to the customer
|
|
func (s *Service) SendBookingCancellation(ctx context.Context, data BookingEmailData) error {
|
|
if data.BusinessEmail == "" {
|
|
data.BusinessEmail = s.cfg.EmailFrom
|
|
}
|
|
data.Type = EmailTypeCancellation
|
|
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 == "" {
|
|
return nil // Skip if no business email configured
|
|
}
|
|
data.Type = EmailTypeBusinessNotify
|
|
msg := RenderEmailMessage(data)
|
|
msg.To = businessEmail
|
|
_, err := s.emailProvider.Send(ctx, msg)
|
|
return err
|
|
}
|
|
|
|
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
|
return RenderReminderEmail(from, job)
|
|
}
|
|
|
|
func renderReminderCopy(job db.ReminderJobRecord) (string, string) {
|
|
startLabel := localizedStartsAt(job)
|
|
|
|
if job.Locale == "cs" {
|
|
return "Pripominka rezervace Bookra", fmt.Sprintf(
|
|
"Dobry den %s,\n\npripominame rezervaci %s u %s na %s.\n\nReference: %s\n",
|
|
job.CustomerName,
|
|
job.Reference,
|
|
job.TenantName,
|
|
startLabel,
|
|
job.Reference,
|
|
)
|
|
}
|
|
|
|
return "Bookra booking reminder", fmt.Sprintf(
|
|
"Hello %s,\n\nthis is a reminder for booking %s with %s at %s.\n\nReference: %s\n",
|
|
job.CustomerName,
|
|
job.Reference,
|
|
job.TenantName,
|
|
startLabel,
|
|
job.Reference,
|
|
)
|
|
}
|
|
|
|
func localizedStartsAt(job db.ReminderJobRecord) string {
|
|
location, err := time.LoadLocation(job.Timezone)
|
|
if err != nil {
|
|
location = time.UTC
|
|
}
|
|
localStartsAt := job.StartsAt.In(location)
|
|
if job.Locale == "cs" {
|
|
return localStartsAt.Format("02.01.2006 15:04")
|
|
}
|
|
return localStartsAt.Format("Jan 02, 2006 15:04")
|
|
}
|
|
|
|
func reminderRecipient(job db.ReminderJobRecord) string {
|
|
return job.CustomerEmail
|
|
}
|
|
|
|
type noopEmailProvider struct{}
|
|
|
|
func (noopEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
|
|
if message.To == "" {
|
|
return DeliveryReceipt{Provider: "noop-email"}, errors.New("missing email recipient")
|
|
}
|
|
return DeliveryReceipt{
|
|
Provider: "noop-email",
|
|
ExternalID: fmt.Sprintf("noop-email-%d", time.Now().UnixNano()),
|
|
}, nil
|
|
}
|
|
|
|
type smtpEmailProvider struct {
|
|
cfg config.Config
|
|
}
|
|
|
|
func (p smtpEmailProvider) Send(_ context.Context, message EmailMessage) (DeliveryReceipt, error) {
|
|
if strings.TrimSpace(message.To) == "" {
|
|
return DeliveryReceipt{Provider: "smtp"}, errors.New("missing email recipient")
|
|
}
|
|
|
|
host := strings.TrimSpace(p.cfg.SMTPHost)
|
|
if host == "" {
|
|
return DeliveryReceipt{Provider: "smtp"}, errors.New("smtp host is not configured")
|
|
}
|
|
|
|
address := net.JoinHostPort(host, strings.TrimSpace(p.cfg.SMTPPort))
|
|
var auth smtp.Auth
|
|
if p.cfg.SMTPUsername != "" {
|
|
auth = smtp.PlainAuth("", p.cfg.SMTPUsername, p.cfg.SMTPPassword, host)
|
|
}
|
|
|
|
// Build multipart email with both plain text and HTML
|
|
var payload string
|
|
if message.HTML != "" {
|
|
boundary := "BOOKRA-BOUNDARY"
|
|
payload = strings.Join([]string{
|
|
fmt.Sprintf("From: %s", message.From),
|
|
fmt.Sprintf("To: %s", message.To),
|
|
fmt.Sprintf("Subject: %s", message.Subject),
|
|
"MIME-Version: 1.0",
|
|
fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"", boundary),
|
|
"",
|
|
fmt.Sprintf("--%s", boundary),
|
|
"Content-Type: text/plain; charset=UTF-8",
|
|
"",
|
|
message.Text,
|
|
"",
|
|
fmt.Sprintf("--%s", boundary),
|
|
"Content-Type: text/html; charset=UTF-8",
|
|
"",
|
|
message.HTML,
|
|
"",
|
|
fmt.Sprintf("--%s--", boundary),
|
|
}, "\r\n")
|
|
} else {
|
|
payload = strings.Join([]string{
|
|
fmt.Sprintf("From: %s", message.From),
|
|
fmt.Sprintf("To: %s", message.To),
|
|
fmt.Sprintf("Subject: %s", message.Subject),
|
|
"MIME-Version: 1.0",
|
|
"Content-Type: text/plain; charset=UTF-8",
|
|
"",
|
|
message.Text,
|
|
}, "\r\n")
|
|
}
|
|
|
|
if err := smtp.SendMail(address, auth, message.From, []string{message.To}, []byte(payload)); err != nil {
|
|
return DeliveryReceipt{Provider: "smtp"}, err
|
|
}
|
|
|
|
return DeliveryReceipt{
|
|
Provider: "smtp",
|
|
ExternalID: fmt.Sprintf("smtp-%d", time.Now().UnixNano()),
|
|
}, nil
|
|
}
|
|
|
|
// SendContactEmail sends a contact form submission to the business email
|
|
func (s *Service) SendContactEmail(ctx context.Context, name, email, message string) error {
|
|
subject := fmt.Sprintf("Bookra Contact: Message from %s", name)
|
|
text := fmt.Sprintf("Name: %s\nEmail: %s\n\nMessage:\n%s", name, email, message)
|
|
html := fmt.Sprintf(
|
|
"<h2>New contact form submission</h2><p><strong>Name:</strong> %s</p><p><strong>Email:</strong> %s</p><p><strong>Message:</strong></p><p>%s</p>",
|
|
name, email, message,
|
|
)
|
|
msg := EmailMessage{
|
|
From: s.cfg.EmailFrom,
|
|
To: s.cfg.EmailFrom,
|
|
Subject: subject,
|
|
Text: text,
|
|
HTML: html,
|
|
}
|
|
_, err := s.emailProvider.Send(ctx, msg)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error {
|
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get tenant: %w", err)
|
|
}
|
|
|
|
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: ownerEmail,
|
|
Locale: tenant.Locale,
|
|
PlanCode: tenant.PlanCode,
|
|
LocationCount: locationCount,
|
|
LocationLimit: locationLimit,
|
|
UsagePercent: usagePercent,
|
|
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
|
|
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
|
|
}
|
|
|
|
msg := RenderUsageNotificationEmail(emailData)
|
|
_, err = s.emailProvider.Send(ctx, msg)
|
|
return err
|
|
}
|
|
|
|
// SendRawEmail sends a pre-built email message
|
|
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
|
|
if msg.From == "" {
|
|
msg.From = s.cfg.EmailFrom
|
|
}
|
|
return s.emailProvider.Send(ctx, msg)
|
|
}
|
|
|
|
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
|
|
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get tenant: %w", err)
|
|
}
|
|
|
|
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: ownerEmail,
|
|
Locale: tenant.Locale,
|
|
PlanCode: tenant.PlanCode,
|
|
UpgradeURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard/billing",
|
|
DashboardURL: strings.TrimRight(s.cfg.FrontendURL, "/") + "/dashboard",
|
|
}
|
|
|
|
msg := RenderUsageNotificationEmail(emailData)
|
|
_, err = s.emailProvider.Send(ctx, msg)
|
|
return err
|
|
}
|