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) }