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" "aiaa-notification-service/internal/tz" ) 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) { rules, err := s.matcher.Match(ctx, req.Source.ID, req.Event) if err != nil || len(rules) == 0 { return Result{Matched: false}, nil } 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"] = tz.Format(time.Now(), "2006.01.02 15:04:05") } var channels []string accepted := 0 for i := range rules { chs, filtered, err := s.dispatch(ctx, req, &rules[i]) if err != nil { return Result{}, err } if filtered { continue } accepted++ channels = append(channels, chs...) } if accepted == 0 { return Result{Matched: true, Filtered: true, Reason: "condition not met"}, nil } slog.Info("notification accepted", "source", req.Source.Name, "event", req.Event, "channels", channels, ) return Result{Matched: true, Channels: channels}, nil } func (s *Service) dispatch(ctx context.Context, req Request, rule *model.Rule) ([]string, bool, error) { 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 nil, false, fmt.Errorf("%w: invalid rule conditions", ErrUnprocessable) } if !condition.Evaluate(conds, req.Data) { return nil, true, nil } } tmpl, err := s.templates.GetTemplate(ctx, rule.TemplateID) if err != nil { return nil, false, fmt.Errorf("template not found") } content, err := s.renderer.Render(tmpl.Content, req.Data) if err != nil { return nil, false, 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 { ruleID := rule.ID srcName := req.Source.Name event := req.Event payloadJSON, _ := json.Marshal(req.Data) chs := append([]string(nil), channels...) go func() { logCtx := context.Background() for _, chName := range chs { ml := &model.MessageLog{ RuleID: ruleID, ChannelID: parseChannelID(chName), Source: srcName, Event: 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) } } }() } return channels, false, 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 }