Artifact d0f20d69f845950c0cebf8a6dfa4ae19410f0dfe83db46bfcf6d66beb70b7459:
- File
internal/notify/notify.go
— part of check-in
[526e25a5d6]
at
2026-08-17 00:32:09
on branch trunk
— notify: address repo activity as a topic, with a key notify accepts
Running the real companion found two faults no stub would have.
NotifyEvent.To is documented as "a user, or a repo:<name> topic", but quarry put that string in subscriberId. notify addresses the two through DIFFERENT fields, so an activity event did not fan out — it invented a subscriber literally named repo:synthetic and every watcher heard nothing. Verified against notify: the message existed, addressed to nobody real.
notify also refuses a topic key containing a colon (lowercase, digits, hyphen, underscore only) and rejects the whole trigger, so quarry's own convention could never have been a valid key. repoTopicKey translates at the boundary; quarry keeps its internal vocabulary. Names differing only in punctuation or case collapse to one key, which is documented rather than hashed away — an operator has to be able to read the key they subscribe someone to.
End to end on a real notify + dagnats: a wiki save reaches every member of repo-synthetic, an @mention reaches the person, and both render in quarry's inbox. (user: dmestas, size: 4415) [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". Exactly one addressing mode applies: // SubscriberID names one person, TopicKey fans out to every member of a // topic. They are DIFFERENT fields in notify's API — putting a topic key // in SubscriberID does not multicast, it silently invents a subscriber // with that name, and the people who should have been notified are not. type Recipient struct { SubscriberID string `json:"subscriberId,omitempty"` Email string `json:"email,omitempty"` TopicKey string `json:"topicKey,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 }