// Outbound notify path (ask-contract.md, "Notify (project to Hub)"). // // A project may tell the Hub something so the Hub can speak or notify. This is // the one call this service makes into the Hub. It is best-effort: the Hub's // /notify receiver is not part of its frozen v1 routes (docs/hub-api.md ยง12), // so a failure is logged and never blocks the local work. package hub import ( "bytes" "context" "fmt" "net/http" "strings" "time" ) // Notifier posts project notifications to the Hub. The nil zero value is not // usable; build one with NewNotifier. type Notifier struct { BaseURL string Token string HTTP *http.Client } // NewNotifier builds a notifier for the Hub at baseURL. timeout defaults to // five seconds when it is zero or negative. func NewNotifier(baseURL, token string, timeout time.Duration, httpClient *http.Client) *Notifier { if timeout <= 0 { timeout = 5 * time.Second } if httpClient == nil { httpClient = &http.Client{Timeout: timeout} } return &Notifier{ BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: httpClient, } } // Configured reports whether the outbound notify path can be attempted. func (n *Notifier) Configured() bool { return n != nil && strings.TrimSpace(n.BaseURL) != "" } // Notify posts one notification. A nil notifier is a no-op so callers do not // need to branch on configuration. func (n *Notifier) Notify(ctx context.Context, req NotifyRequest) error { if !n.Configured() { return fmt.Errorf("hub notify is not configured") } if strings.TrimSpace(req.From) == "" { req.From = ProjectName } body, err := jsonBody(req) if err != nil { return fmt.Errorf("encode notify: %w", err) } url := n.BaseURL + "/notify" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build notify request: %w", err) } httpReq.Header.Set("Content-Type", "application/json; charset=utf-8") if n.Token != "" { httpReq.Header.Set("Authorization", "Bearer "+n.Token) } resp, err := n.HTTP.Do(httpReq) if err != nil { return fmt.Errorf("post notify: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("post notify: status %d", resp.StatusCode) } return nil } // NotifySummaryReady is the convenience call used after a daily summary is // built. func (n *Notifier) NotifySummaryReady(ctx context.Context, message string) error { return n.Notify(ctx, NotifyRequest{ From: ProjectName, Level: "info", Message: message, Person: "sam", }) } // NotifyImportantCount tells the Hub how many messages need attention. It is // used by the daily summary job when the count is non-zero. func (n *Notifier) NotifyImportantCount(ctx context.Context, n2 int) error { if n2 <= 0 { return nil } return n.Notify(ctx, NotifyRequest{ From: ProjectName, Level: "important", Message: fmt.Sprintf("%d emails need a decision.", n2), Person: "sam", }) }