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.