Artifact 702d2ea35151d8b380809c36252cd637a5ddc5e4c2c80cf2e0a7175dbcc01233:
- File
internal/notify/notify.go
— part of check-in
[e33f1c4e8c]
at
2026-08-17 00:19:10
on branch trunk
— notify: authenticate triggers, and embed the inbox
Two things, one of them a live bug. notify's /v1 routes sit behind an API-key middleware and quarry's client sent no Authorization header, so every trigger it fired would have been answered 401. It went unnoticed because --notify was never configured and a trigger failure is logged rather than raised. --notify-key (or QUARRY_NOTIFY_KEY) supplies it, and serve says so at startup when it is missing rather than leaving the answer to be discovered in the log.
The inbox is notify's, embedded (0001 §5): GET /v1/messages?channel=in_app for the signed-in account, rendered with unread emphasised. quarry models no notification state — it displays what notify owns.
The wire field names are notify's Go field names: its domain.Message carries db tags but no json tags, so the encoder falls back to the identifiers. A test pins that, since decoding lowercase would silently produce empty subjects.
An outage is not emptiness: a failed fetch says notifications are unavailable instead of showing an empty list, which would be a lie about whether anyone had written to you. (user: dmestas, size: 4128) [annotate] [blame] [check-ins using]
// Package notify is quarry's thin edge to the notify companion (0001 // §5/§8): quarry does not build notification logic, it fires triggers. // A trigger is the Novu-compatible POST /v1/events/trigger the notify // RFC preserves as its public contract; notify resolves recipients, // runs the workflow DAG, and owns channels, digests, and the inbox. // Triggers are best-effort — a notify outage must never fail the user // action that raised it. package notify import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" ) // Client posts triggers to a notify base URL. A nil Client (base "") is // the disabled no-op: Trigger returns nil without a call. type Client struct { base string key string http *http.Client } // New returns a client for baseURL, or a disabled client when baseURL // is empty (notify not configured). apiKey is notify's console key: its // /v1 routes sit behind an API-key middleware, so a client without one // is answered 401 on every call. func New(baseURL, apiKey string) *Client { return &Client{base: baseURL, key: apiKey, http: &http.Client{Timeout: 5 * time.Second}} } // auth applies notify's scheme. Named rather than inlined so the two // callers cannot drift. func (c *Client) auth(req *http.Request) { if c.key != "" { req.Header.Set("Authorization", "ApiKey "+c.key) } } // Message is one in-app notification as notify serves it. // // The field names are notify's wire names, which are its Go field names: // domain.Message carries db tags but no json tags, so the encoder falls // back to the identifiers. Renaming a field there renames it here. type Message struct { ID string Subject string Content string Seen bool Read bool CreatedAt string } // Inbox returns a subscriber's in-app messages, newest first. notify // owns the notification model — quarry only displays it, which is what // "embed the inbox" means (0001 §5). func (c *Client) Inbox(ctx context.Context, subscriberID string) ([]Message, error) { if c == nil || c.base == "" || subscriberID == "" { return nil, nil } u := c.base + "/v1/messages?channel=in_app&subscriberId=" + url.QueryEscape(subscriberID) req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return nil, err } c.auth(req) resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, fmt.Errorf("notify: inbox: status %d", resp.StatusCode) } var msgs []Message if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&msgs); err != nil { return nil, fmt.Errorf("notify: inbox: %w", err) } return msgs, nil } // Recipient is a trigger's "to": a subscriber id (a quarry user name), // optionally carrying a channel identity notify can deliver on. type Recipient struct { SubscriberID string `json:"subscriberId"` Email string `json:"email,omitempty"` } type triggerBody struct { Name string `json:"name"` To Recipient `json:"to"` Payload map[string]any `json:"payload"` TransactionID string `json:"transactionId,omitempty"` } // Trigger starts the named workflow for one recipient. transactionID is // the idempotency key (notify maps it to dagnats WithIdempotencyKey), so // a replayed or duplicated event notifies at most once. A disabled // client no-ops. func (c *Client) Trigger(ctx context.Context, name string, to Recipient, payload map[string]any, transactionID string) error { if c == nil || c.base == "" { return nil } body, err := json.Marshal(triggerBody{Name: name, To: to, Payload: payload, TransactionID: transactionID}) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/v1/events/trigger", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") c.auth(req) resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("notify: trigger %q: status %d", name, resp.StatusCode) } return nil }