feat(reminders): SMS channel for reminder jobs

Tenants paying for the SMS add-on still got email-only reminders —
the channel column existed but nothing armed or dispatched sms jobs.
Booking creation now adds a second job when SMS is enabled and the
customer left a phone; dispatch routes sms jobs through an injected
sender adapter wired to the existing sms.Service, so monthly limits,
usage metering, and Stripe billing apply unchanged. Lifecycle handling
from the previous milestone clears and re-arms both channels together.

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-18 13:03:11 +02:00
1 parent c7172544b8
commit 7509e82261
8 files changed
+164 -1

No files matched your search

@@ -37,6 +37,7 @@ 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
}
@@ -54,6 +55,13 @@ func NewService(cfg config.Config, repo db.Repository) *Service {
}
}
// 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
@@ -91,6 +99,21 @@ func (s *Service) DispatchDue(ctx context.Context, limit int) (domain.DispatchRe
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()
@@ -275,7 +298,18 @@ func localizedStartsAt(job db.ReminderJobRecord) string {
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
}