package notify import ( "bytes" "context" "encoding/json" "fmt" "log" "net/http" "time" ) // Event represents a deployment notification payload. type Event struct { Type string `json:"type"` // "deploy_success" or "deploy_failure" Project string `json:"project"` Stage string `json:"stage"` ImageTag string `json:"image_tag"` Subdomain string `json:"subdomain"` URL string `json:"url,omitempty"` Error string `json:"error,omitempty"` Timestamp string `json:"timestamp"` } // Notifier sends webhook notifications for deploy events. // Notifications are fire-and-forget — failures are logged but do not propagate. type Notifier struct { httpClient *http.Client } // New creates a Notifier with sensible defaults. func New() *Notifier { return &Notifier{ httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // Send sends a notification event to the given webhook URL in a background goroutine. // It does not block the caller. Errors are logged, not returned. func (n *Notifier) Send(webhookURL string, event Event) { if webhookURL == "" { return } if event.Timestamp == "" { event.Timestamp = time.Now().UTC().Format(time.RFC3339) } go func() { if err := n.doSend(context.Background(), webhookURL, event); err != nil { log.Printf("notify: failed to send webhook to %s: %v", webhookURL, err) } }() } // doSend performs the actual HTTP POST to the webhook URL. func (n *Notifier) doSend(ctx context.Context, webhookURL string, event Event) error { body, err := json.Marshal(event) if err != nil { return fmt.Errorf("marshal notification: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create notification request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := n.httpClient.Do(req) if err != nil { return fmt.Errorf("send notification: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("notification webhook returned status %d", resp.StatusCode) } return nil }