mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
1 parent
c7172544b8
commit
7509e82261
8 files changed
+164
-1
No files matched your search
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "[email protected]",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 := "[email protected]"
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user