From 7509e82261dad29aacf145ef33e80bceff87a63e Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 18 Sep 2026 13:03:11 +0200 Subject: [PATCH] feat(reminders): SMS channel for reminder jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- apps/backend/internal/api/server.go | 4 ++ apps/backend/internal/bookings/service.go | 11 ++++ .../backend/internal/bookings/service_test.go | 55 +++++++++++++++++++ apps/backend/internal/db/bookings.go | 3 +- apps/backend/internal/db/repository.go | 5 ++ .../backend/internal/notifications/service.go | 34 ++++++++++++ .../internal/notifications/service_test.go | 47 ++++++++++++++++ project.md | 6 ++ 8 files changed, 164 insertions(+), 1 deletion(-) diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 2aab16b..3a7fc75 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -57,6 +57,10 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { authService := auth.NewService(repository, cfg.AuthJWTSecret) adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey) smsService := sms.NewService(cfg, repository) + notificationService.SetSMSSender(func(ctx context.Context, tenantID, phone, body string) error { + _, err := smsService.SendMessage(ctx, tenantID, domain.SendSMSRequest{To: phone, Body: body}) + return err + }) publicRateLimiter := httpx.NewRateLimiter(rate.Every(time.Second), 5) server := &Server{ diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 6e32cfb..9c817d4 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -366,6 +366,17 @@ func (s *Service) Create(ctx context.Context, request domain.CreateBookingReques }); err != nil { return domain.CreateBookingResponse{}, err } + // SMS reminders are a per-tenant opt-in and need a customer phone. + if request.CustomerPhone != "" { + if smsSettings, err := s.repo.GetTenantSMSSettings(ctx, tenant.ID); err == nil && smsSettings.Enabled { + _ = s.repo.CreateReminderJob(ctx, db.ReminderJobParams{ + TenantID: tenant.ID, + BookingID: created.ID, + Channel: "sms", + ScheduledFor: scheduledFor, + }) + } + } } // Send confirmation emails (async - don't fail booking if email fails) diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index 2e38fbd..b819333 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -648,3 +648,58 @@ func TestRescheduleRejectsTimeOutsideAvailabilityWindow(t *testing.T) { t.Fatalf("expected ErrInvalidReschedule, got %v", err) } } + +func TestCreateArmsSmsReminderWhenEnabled(t *testing.T) { + repo := db.NewMemoryRepository() + service := NewService(repo, nil, "http://localhost:3000") + ctx := context.Background() + + membership, _ := repo.GetTenantMembershipBySubject(ctx, "demo-owner") + if err := repo.UpsertTenantSMSSettings(ctx, db.TenantSMSSettingsRecord{ + TenantID: membership.Tenant.ID, + Enabled: true, + }); err != nil { + t.Fatalf("sms settings: %v", err) + } + + // Booking with a phone: expect email + sms reminder jobs. + availability, err := service.Availability(ctx, "studio-atelier", AvailabilityFilter{ + Date: time.Now().UTC().AddDate(0, 0, 3).Format("2006-01-02"), + }) + if err != nil { + t.Fatalf("availability: %v", err) + } + var slot domain.TimeSlot + for _, s := range availability.Slots { + if s.Mode == "appointment" { + slot = s + break + } + } + resp, err := service.Create(ctx, domain.CreateBookingRequest{ + TenantSlug: "studio-atelier", + BookingMode: "appointment", + ServiceID: slot.ServiceID, + StaffID: slot.StaffID, + LocationID: slot.LocationID, + CustomerName: "SMS Test", + CustomerEmail: "sms@test.cz", + CustomerPhone: "+420777123456", + StartsAt: slot.StartsAt, + EndsAt: slot.EndsAt, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + jobs := pendingJobsFor(t, repo, resp.BookingID) + if len(jobs) != 2 { + t.Fatalf("expected 2 pending jobs, got %d", len(jobs)) + } + channels := map[string]bool{} + for _, job := range jobs { + channels[job.Channel] = true + } + if !channels["email"] || !channels["sms"] { + t.Fatalf("expected email+sms channels, got %v", channels) + } +} diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go index 163da14..4c0df3d 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -108,7 +108,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T rows, err := r.pool.Query(ctx, ` SELECT rj.id, rj.tenant_id, t.name, t.locale, t.timezone, rj.booking_id, rj.channel, rj.scheduled_for, - b.customer_name, b.customer_email, b.reference, b.starts_at, rj.status, b.status + b.customer_name, b.customer_email, b.customer_phone, b.reference, b.starts_at, rj.status, b.status FROM reminder_jobs rj INNER JOIN bookings b ON b.id = rj.booking_id INNER JOIN tenants t ON t.id = rj.tenant_id @@ -135,6 +135,7 @@ func (r *PGRepository) ListDueReminderJobs(ctx context.Context, dueBefore time.T &record.ScheduledFor, &record.CustomerName, &record.CustomerEmail, + &record.CustomerPhone, &record.Reference, &record.StartsAt, &record.Status, diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 65dc6a5..33daead 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -418,6 +418,7 @@ type ReminderJobRecord struct { ScheduledFor time.Time CustomerName string CustomerEmail string + CustomerPhone string Reference string StartsAt time.Time Status string @@ -1386,12 +1387,14 @@ func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJ startsAt := time.Now().UTC() customerName := "Booking Customer" customerEmail := "customer@example.com" + customerPhone := "" for _, booking := range r.bookings { if booking.ID == params.BookingID { reference = booking.Reference startsAt = booking.StartsAt customerName = booking.CustomerName customerEmail = booking.CustomerEmail + customerPhone = booking.CustomerPhone break } } @@ -1406,6 +1409,7 @@ func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJ ScheduledFor: params.ScheduledFor, CustomerName: customerName, CustomerEmail: customerEmail, + CustomerPhone: customerPhone, Reference: reference, StartsAt: startsAt, Status: "pending", @@ -1420,6 +1424,7 @@ func (r *MemoryRepository) ListDueReminderJobs(_ context.Context, dueBefore time for _, booking := range r.bookings { if booking.ID == job.BookingID { job.BookingStatus = booking.Status + job.CustomerPhone = booking.CustomerPhone break } } diff --git a/apps/backend/internal/notifications/service.go b/apps/backend/internal/notifications/service.go index b8298cd..01b6e6d 100644 --- a/apps/backend/internal/notifications/service.go +++ b/apps/backend/internal/notifications/service.go @@ -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 } diff --git a/apps/backend/internal/notifications/service_test.go b/apps/backend/internal/notifications/service_test.go index 4676713..f6f4e5a 100644 --- a/apps/backend/internal/notifications/service_test.go +++ b/apps/backend/internal/notifications/service_test.go @@ -138,3 +138,50 @@ func TestDispatchDueSkipsNonConfirmedBooking(t *testing.T) { t.Fatalf("expected 1 skipped, got %+v", response) } } + +func TestDispatchDueSendsSmsReminder(t *testing.T) { + repo := db.NewMemoryRepository() + startsAt := time.Now().UTC().Add(26 * time.Hour) + created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingMode: "appointment", + CustomerPhone: "+420777000111", + StartsAt: startsAt, + EndsAt: startsAt.Add(time.Hour), + Status: "confirmed", + Reference: "BK-TEST-SMS", + }) + if err != nil { + t.Fatalf("create booking: %v", err) + } + if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{ + TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148", + BookingID: created.ID, + Channel: "sms", + ScheduledFor: time.Now().UTC().Add(-time.Minute), + }); err != nil { + t.Fatalf("create reminder job: %v", err) + } + + var sentPhone, sentBody string + service := NewService(config.Config{Environment: "development"}, repo) + service.SetSMSSender(func(_ context.Context, tenantID, phone, body string) error { + sentPhone = phone + sentBody = body + return nil + }) + + response, err := service.DispatchDue(context.Background(), 10) + if err != nil { + t.Fatalf("dispatch due: %v", err) + } + if response.SentCount != 1 { + t.Fatalf("expected 1 sent, got %+v", response) + } + if sentPhone != "+420777000111" { + t.Fatalf("expected phone recipient, got %q", sentPhone) + } + if sentBody == "" { + t.Fatal("expected rendered sms body") + } +} diff --git a/project.md b/project.md index 5f81fdf..e90a6f1 100644 --- a/project.md +++ b/project.md @@ -101,6 +101,12 @@ - KPI grid rebalanced for five cards (`lg:grid-cols-3 xl:grid-cols-5`); `new_clients` maps to the existing `dashboard.kpi.newClients` i18n key - reminder dispatch now has a real trigger: `.github/workflows/reminder-dispatch.yml` calls `POST /v1/internal/jobs/reminders/dispatch` every 15 minutes with `X-Bookra-Job-Key` from secrets (`BOOKRA_API_URL`, `BOOKRA_JOB_RUNNER_KEY`); jobs are timestamp-based so scheduler lateness delays but never skips - remaining: live staging validation; the workflow no-ops gracefully until the two secrets are configured +- `[done/partial]` Milestone 15: SMS reminder channel + - booking creation arms a second reminder job on `channel: sms` when the tenant has SMS enabled (`tenant_sms_settings.enabled`) and the customer left a phone number; email jobs are unchanged + - `notifications.Service` gained an injected `smsSender` function adapter (wired to `sms.Service.SendMessage` in the API server, keeping the package dependency-free); dispatch routes `channel: sms` through it with localized body text and the customer phone as recipient + - `sms.Service.SendMessage` still enforces enabled/plan/monthly-limit and logs usage, so SMS reminders inherit metering and billing automatically; missing provider config or phone marks the job `failed` with a delivery-log reason + - reminder lifecycle work from milestone 13 applies per-booking, so cancel/reschedule clears and re-arms both channels + - remaining: live staging validation against a real SMS Manager key ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.