mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
notification_cursors table stores one read-cursor per user per tenant; GET /v1/activity returns unreadCount + readAt, POST /v1/activity/read persists the cursor. Reminder dispatches now append reminder.sent / reminder.failed system events so delivery history reaches the feed. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
491 lines
14 KiB
Go
491 lines
14 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
|
|
smsSender func(ctx context.Context, tenantID, phone, body string) error
|
|
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() },
|
|
}
|
|
}
|
|
|
|
// SetSMSSender plugs in the SMS delivery path used for sms-channel reminder
|
|
// jobs. Kept as a function adapter so this package stays free of the sms
|
|
// dependency; the concrete sms.Service is wired in the API server.
|
|
func (s *Service) SetSMSSender(sender func(ctx context.Context, tenantID, phone, body string) error) {
|
|
s.smsSender = sender
|
|
}
|
|
|
|
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
|
|
}
|
|
case "sms":
|
|
provider = "smsmanager"
|
|
switch {
|
|
case s.smsSender == nil:
|
|
status = "failed"
|
|
errorMessage = "sms delivery not configured"
|
|
case job.CustomerPhone == "":
|
|
status = "failed"
|
|
errorMessage = "missing customer phone"
|
|
default:
|
|
if sendErr := s.smsSender(ctx, job.TenantID, job.CustomerPhone, renderSmsBody(job)); sendErr != nil {
|
|
status = "failed"
|
|
errorMessage = sendErr.Error()
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
action := "reminder.sent"
|
|
if status != "sent" {
|
|
action = "reminder.failed"
|
|
}
|
|
_ = s.repo.AppendActivityEvent(ctx, db.ActivityEventParams{
|
|
TenantID: job.TenantID,
|
|
ActorKind: "system",
|
|
Action: action,
|
|
EntityType: "booking",
|
|
EntityID: &job.BookingID,
|
|
Summary: job.CustomerName + " — " + job.Channel,
|
|
})
|
|
|
|
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 renderSmsBody(job db.ReminderJobRecord) string {
|
|
startLabel := localizedStartsAt(job)
|
|
if job.Locale == "cs" {
|
|
return fmt.Sprintf("%s: pripominka rezervace %s na %s.", job.TenantName, job.Reference, startLabel)
|
|
}
|
|
return fmt.Sprintf("%s: reminder for booking %s at %s.", job.TenantName, job.Reference, startLabel)
|
|
}
|
|
|
|
func reminderRecipient(job db.ReminderJobRecord) string {
|
|
if job.Channel == "sms" {
|
|
return job.CustomerPhone
|
|
}
|
|
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
|
|
}
|