mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 20:43:01 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
type SMSMessage struct {
|
||||
From string
|
||||
To string
|
||||
Text string
|
||||
}
|
||||
|
||||
type EmailProvider interface {
|
||||
Send(context.Context, EmailMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, SMSMessage) (DeliveryReceipt, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
repo db.Repository
|
||||
emailProvider EmailProvider
|
||||
smsProvider SMSProvider
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(cfg config.Config, repo db.Repository) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
repo: repo,
|
||||
emailProvider: noopEmailProvider{},
|
||||
smsProvider: noopSMSProvider{},
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}
|
||||
}
|
||||
|
||||
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++
|
||||
|
||||
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":
|
||||
receipt, sendErr := s.smsProvider.Send(ctx, renderSMSMessage(s.cfg.SMSFrom, job))
|
||||
if sendErr != nil {
|
||||
status = "failed"
|
||||
errorMessage = sendErr.Error()
|
||||
} else {
|
||||
provider = receipt.Provider
|
||||
externalID = receipt.ExternalID
|
||||
}
|
||||
default:
|
||||
status = "failed"
|
||||
errorMessage = ErrUnsupportedChannel.Error()
|
||||
}
|
||||
|
||||
if provider == "unknown" {
|
||||
if job.Channel == "email" {
|
||||
provider = "noop-email"
|
||||
} else if job.Channel == "sms" {
|
||||
provider = "noop-sms"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if status == "sent" {
|
||||
response.SentCount++
|
||||
} else {
|
||||
response.FailedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func renderEmailMessage(from string, job db.ReminderJobRecord) EmailMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return EmailMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Subject: subject,
|
||||
Text: body,
|
||||
}
|
||||
}
|
||||
|
||||
func renderSMSMessage(from string, job db.ReminderJobRecord) SMSMessage {
|
||||
subject, body := renderReminderCopy(job)
|
||||
return SMSMessage{
|
||||
From: from,
|
||||
To: job.CustomerEmail,
|
||||
Text: fmt.Sprintf("%s: %s", subject, body),
|
||||
}
|
||||
}
|
||||
|
||||
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 reminderRecipient(job db.ReminderJobRecord) string {
|
||||
if job.Channel == "email" {
|
||||
return job.CustomerEmail
|
||||
}
|
||||
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 noopSMSProvider struct{}
|
||||
|
||||
func (noopSMSProvider) Send(_ context.Context, message SMSMessage) (DeliveryReceipt, error) {
|
||||
if message.To == "" {
|
||||
return DeliveryReceipt{Provider: "noop-sms"}, errors.New("missing sms recipient")
|
||||
}
|
||||
return DeliveryReceipt{
|
||||
Provider: "noop-sms",
|
||||
ExternalID: fmt.Sprintf("noop-sms-%d", time.Now().UnixNano()),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookra/apps/backend/internal/config"
|
||||
"bookra/apps/backend/internal/db"
|
||||
"bookra/apps/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestDispatchDueProcessesPendingEmailReminders(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",
|
||||
CustomerName: "Reminder Customer",
|
||||
CustomerEmail: "reminder@example.com",
|
||||
StartsAt: startsAt,
|
||||
EndsAt: startsAt.Add(time.Hour),
|
||||
Status: "confirmed",
|
||||
Reference: "BK-TEST-REMINDER",
|
||||
})
|
||||
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: "email",
|
||||
ScheduledFor: time.Now().UTC().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("create reminder job: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(config.Config{
|
||||
Environment: "development",
|
||||
EmailFrom: "noreply@bookra.dev",
|
||||
SMSFrom: "Bookra",
|
||||
}, repo)
|
||||
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch due: %v", err)
|
||||
}
|
||||
if response.ProcessedCount != 1 {
|
||||
t.Fatalf("expected processed count 1, got %d", response.ProcessedCount)
|
||||
}
|
||||
if response.SentCount != 1 {
|
||||
t.Fatalf("expected sent count 1, got %d", response.SentCount)
|
||||
}
|
||||
if response.FailedCount != 0 {
|
||||
t.Fatalf("expected failed count 0, got %d", response.FailedCount)
|
||||
}
|
||||
|
||||
pending, err := repo.ListDueReminderJobs(context.Background(), time.Now().UTC().Add(time.Hour), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list due reminder jobs: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("expected no pending jobs after dispatch, got %d", len(pending))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchDueFailsUnknownChannel(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
if err := repo.CreateReminderJob(context.Background(), db.ReminderJobParams{
|
||||
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||
BookingID: "booking-unknown-channel",
|
||||
Channel: "push",
|
||||
ScheduledFor: time.Now().UTC().Add(-time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("create reminder job: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(config.Config{Environment: "development"}, repo)
|
||||
response, err := service.DispatchDue(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch due: %v", err)
|
||||
}
|
||||
if response.ProcessedCount != 1 || response.FailedCount != 1 {
|
||||
t.Fatalf("expected one failed job, got %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRequestContractShape(t *testing.T) {
|
||||
request := domain.DispatchReminderJobsRequest{Limit: 20}
|
||||
if request.Limit != 20 {
|
||||
t.Fatalf("expected limit 20, got %d", request.Limit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user