Implement notifications service with shared messaging and email drivers (#36)

This commit is contained in:
Stephan D committed 2026-09-12 14:41:55 +02:00
1 parent 36277b6c45
commit 5c2d36281b
54 files changed
+3746 -6

No files matched your search

+109
View File
@@ -0,0 +1,109 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"git.sendico.io/tech/sxchnge/notifications/email"
"git.sendico.io/tech/sxchnge/notifications/model"
"git.sendico.io/tech/sxchnge/notifications/templates"
"git.sendico.io/tech/sxchnge/shared/database/record"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type Repository interface {
Find(context.Context, string, string) (*model.Notification, error)
Get(context.Context, string, string) (*model.Notification, error)
Create(context.Context, *model.Notification) (*model.Notification, error)
Claim(context.Context, string, time.Time) (*model.Notification, error)
Finish(context.Context, *model.Notification) error
Recover(context.Context, time.Time) error
}
type Service struct {
repo Repository
catalog *templates.Catalog
sender email.Sender
driver string
logger mlogger.Logger
}
func New(repo Repository, catalog *templates.Catalog, sender email.Sender, driver string, logger mlogger.Logger) *Service {
return &Service{repo, catalog, sender, driver, mlogger.Named(logger, "notifications")}
}
func (s *Service) Send(ctx context.Context, caller string, r model.Request) (*model.Notification, error) {
if caller == "" {
return nil, merrors.Unauthorized("missing caller")
}
if r.Key == "" || len(r.Key) > 128 || r.UserID == "" || len(r.UserID) > 128 || !email.ValidAddress(r.Address) || len(r.TemplateID) > 128 || len(r.TemplateVersion) > 64 || len(r.Locale) > 64 || strings.ContainsRune(r.TemplateID+r.TemplateVersion, 0) {
return nil, merrors.InvalidArgument("invalid notification request")
}
if len(r.Parameters) > 64 {
return nil, merrors.InvalidArgument("too many parameters")
}
for k, v := range r.Parameters {
if k == "" || len(k) > 64 || len(v) > 4096 {
return nil, merrors.InvalidArgument("invalid template parameter")
}
}
payload, err := json.Marshal(r)
if err != nil {
return nil, merrors.InvalidArgument("invalid request")
}
if len(payload) > 64<<10 {
return nil, merrors.InvalidArgument("request too large")
}
sum := sha256.Sum256(payload)
fingerprint := hex.EncodeToString(sum[:])
existing, err := s.repo.Find(ctx, caller, r.Key)
if err == nil {
return same(existing, fingerprint)
}
if !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
now := time.Now().UTC()
expiry := r.ExpiresAt
if expiry.IsZero() {
expiry = now.Add(24 * time.Hour)
}
if !expiry.After(now) || expiry.After(now.Add(24*time.Hour)) {
return nil, merrors.InvalidArgument("expiry must be within the next 24 hours")
}
rendered, err := s.catalog.Render(r)
if err != nil {
return nil, err
}
n := &model.Notification{Base: storable.Base{ID: bson.NewObjectID(), CreatedAt: now, UpdatedAt: now}, Caller: caller, Key: r.Key, Fingerprint: fingerprint, UserID: r.UserID, TemplateID: r.TemplateID, TemplateVersion: r.TemplateVersion, Email: rendered, Driver: s.driver, State: model.Queued, ExpiresAt: expiry, NextAttemptAt: now, PurgeAt: now.Add(7 * 24 * time.Hour)}
stored, err := s.repo.Create(ctx, n)
if err != nil {
return nil, err
}
result, err := same(stored, fingerprint)
if err == nil {
s.logger.Info("Notification accepted", zap.String("notification_id", stored.ID.Hex()), zap.String("caller", caller))
}
return result, err
}
func same(n *model.Notification, fingerprint string) (*model.Notification, error) {
if n.Fingerprint != fingerprint {
return nil, merrors.DataConflict("idempotency key already used with a different request")
}
return n, nil
}
func (s *Service) Get(ctx context.Context, caller, id string) (*model.Notification, error) {
if caller == "" {
return nil, merrors.Unauthorized("missing caller")
}
if _, err := bson.ObjectIDFromHex(id); err != nil {
return nil, merrors.InvalidArgument("invalid notification ID")
}
return s.repo.Get(ctx, caller, id)
}