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 }