package notify import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strconv" "strings" "time" "aiaa-notification-service/internal/condition" "aiaa-notification-service/internal/engine" "aiaa-notification-service/internal/model" ) var ErrUnprocessable = errors.New("unprocessable") type Request struct { Source *model.Source Event string Data map[string]interface{} } type Result struct { Matched bool Filtered bool Channels []string Reason string } type RuleMatcher interface { Match(ctx context.Context, sourceID int, event string) (*model.Rule, error) } type TemplateStore interface { GetTemplate(ctx context.Context, id int) (*model.Template, error) } type ChannelRouter interface { Route(ctx context.Context, rule *model.Rule, title, content string) []string } type MessageLogger interface { CreateMessageLog(ctx context.Context, ml *model.MessageLog) error } type Service struct { matcher RuleMatcher templates TemplateStore renderer *engine.Renderer router ChannelRouter logs MessageLogger } func NewService(m RuleMatcher, t TemplateStore, r *engine.Renderer, rt ChannelRouter, logs MessageLogger) *Service { return &Service{matcher: m, templates: t, renderer: r, router: rt, logs: logs} } func (s *Service) Process(ctx context.Context, req Request) (Result, error) { rule, err := s.matcher.Match(ctx, req.Source.ID, req.Event) if err != nil { return Result{Matched: false}, nil } if rule.Conditions != nil { var conds []model.Condition if err := json.Unmarshal(*rule.Conditions, &conds); err != nil { slog.Error("failed to unmarshal rule conditions", "rule_id", rule.ID, "error", err) return Result{}, fmt.Errorf("%w: invalid rule conditions", ErrUnprocessable) } if !condition.Evaluate(conds, req.Data) { return Result{Matched: true, Filtered: true, Reason: "condition not met"}, nil } } tmpl, err := s.templates.GetTemplate(ctx, rule.TemplateID) if err != nil { return Result{}, fmt.Errorf("template not found") } if req.Data == nil { req.Data = map[string]interface{}{} } if _, ok := req.Data["event"]; !ok && req.Event != "" { req.Data["event"] = req.Event } if _, ok := req.Data["pushedAt"]; !ok { req.Data["pushedAt"] = time.Now().In(time.Local).Format("2006.01.02 15:04:05") } content, err := s.renderer.Render(tmpl.Content, req.Data) if err != nil { return Result{}, fmt.Errorf("%w: template render failed: %s", ErrUnprocessable, err.Error()) } title := req.Source.Name + ": " + req.Event channels := s.router.Route(ctx, rule, title, content) if s.logs != nil { go func() { payloadJSON, _ := json.Marshal(req.Data) logCtx := context.Background() for _, chName := range channels { ml := &model.MessageLog{ RuleID: rule.ID, ChannelID: parseChannelID(chName), Source: req.Source.Name, Event: req.Event, Payload: payloadJSON, Content: content, Status: "pending", } if err := s.logs.CreateMessageLog(logCtx, ml); err != nil { slog.Warn("failed to create message log", "error", err) } } }() } slog.Info("notification accepted", "source", req.Source.Name, "event", req.Event, "channels", channels, ) return Result{Matched: true, Channels: channels}, nil } func parseChannelID(chName string) int { idx := strings.LastIndex(chName, ":") if idx < 0 || idx == len(chName)-1 { return 0 } id, err := strconv.Atoi(chName[idx+1:]) if err != nil { return 0 } return id }