Files
zeekay e285e18128 feat: bootstrap notifyd service layer on hanzoai/base + hanzoai/tasks
Replaces the chi-based notifyd scaffold with the canonical
hanzoai/auto-style layout: hanzoai/base as the HTTP + SQLite + auth
substrate (via the platform plugin for IAM JWT validation and
X-Org-Id injection), hanzoai/tasks for durable async sends, and a
per-tenant Notifier resolver that pulls provider credentials from
KMS through platform.KMSClient.

Library at the module root stays untouched — all 33 provider impls
(plivo, twilio, ses, sendgrid, mail, whatsapp, discord, slack, …)
keep their existing public API. The service wraps them.

cmd/notifyd
  - base.New + platform.MustRegister
  - schema.MustRegister (6 collections: tenants, providers,
    templates, events, messages, meter)
  - tasks worker registered when TASKS_ADDR is set; sync path
    runs the same Deliver activity directly when not

internal/
  - boot/      thin platform.KMSClient wiring (env-driven)
  - schema/    6 base collections
  - routes/    /v1/notify/{send,messages,providers,templates,events,metering,health}
               + convenience /send/{sms,email,voice,whatsapp}
  - tenant/    KMS-backed resolver builds library Notifier per send
  - template/  draft → pending → approved → published → archived
  - event/     catalog routing (fills missing channel / template)
  - metering/  per-send ledger writer + aggregate query
  - tasks/     NotifySendWorkflow + Deliver activity (idempotent on
               replay; reloads message row before resending)
  - zaprpc/    procedure name constants (server impl follow-up)

pkg/
  - client/    public HTTP SDK (Send / Message / Providers / Templates)
  - types/     wire shapes shared by routes and client

cucloud: fmt.Errorf(msg) → errors.New(msg) for the go 1.26 vet pass
that landed with this dependency bump. Behaviour unchanged.

Boot smoke-tested locally:
  - SQLite migrations apply cleanly
  - /v1/notify/health returns {"service":"notify","status":"ok"}
  - missing X-Org-Id returns 401 on every authenticated route
  - send with stub tenant returns the expected base validation error
2026-06-01 16:06:27 -07:00

178 lines
4.8 KiB
Go

// Package client is the thin HTTP consumer SDK for the Hanzo Notify
// service. Targets the /v1/notify/* surface notifyd exposes; every
// method maps 1:1 to one HTTP endpoint with no client-side fan-out.
//
// Auth is bearer-token: callers provide a Hanzo IAM JWT (via
// WithToken) which notifyd validates against IAM's JWKS.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/hanzoai/notify/pkg/types"
)
// Client is the canonical HTTP consumer SDK.
type Client struct {
base *url.URL
token string
httpClient *http.Client
}
// Option configures a Client at construction time.
type Option func(*Client)
// WithToken sets the IAM JWT used for the Authorization header.
func WithToken(jwt string) Option {
return func(c *Client) { c.token = jwt }
}
// WithHTTPClient swaps the underlying http.Client (e.g. for retries,
// tracing, or test fakes).
func WithHTTPClient(h *http.Client) Option {
return func(c *Client) { c.httpClient = h }
}
// New returns a Client targeting baseURL (e.g. "https://notify.hanzo.ai").
// baseURL is parsed once at construction; an invalid URL returns an
// error rather than panicking later in a Send call.
func New(baseURL string, opts ...Option) (*Client, error) {
if baseURL == "" {
return nil, errors.New("client: baseURL is required")
}
u, err := url.Parse(strings.TrimRight(baseURL, "/"))
if err != nil {
return nil, fmt.Errorf("client: parse base url: %w", err)
}
c := &Client{
base: u,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// Send dispatches one notification. With sync=false (the default
// async mode) notifyd enqueues a hanzoai/tasks workflow and returns a
// task_id immediately; with sync=true notifyd blocks until the provider
// either accepts the message or returns an error.
func (c *Client) Send(ctx context.Context, req types.SendRequest, sync bool) (*types.SendResponse, error) {
q := url.Values{}
if sync {
q.Set("sync", "true")
}
var out types.SendResponse
if err := c.do(ctx, http.MethodPost, "/v1/notify/send", q, req, &out); err != nil {
return nil, err
}
return &out, nil
}
// Message fetches the persisted record by id.
func (c *Client) Message(ctx context.Context, id string) (*types.Message, error) {
var out types.Message
if err := c.do(ctx, http.MethodGet, "/v1/notify/messages/"+url.PathEscape(id), nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// Providers lists the configured providers for the authenticated tenant.
func (c *Client) Providers(ctx context.Context) ([]types.Provider, error) {
var out struct {
Items []types.Provider `json:"items"`
}
if err := c.do(ctx, http.MethodGet, "/v1/notify/providers", nil, nil, &out); err != nil {
return nil, err
}
return out.Items, nil
}
// Templates lists the configured templates for the authenticated tenant.
func (c *Client) Templates(ctx context.Context) ([]types.Template, error) {
var out struct {
Items []types.Template `json:"items"`
}
if err := c.do(ctx, http.MethodGet, "/v1/notify/templates", nil, nil, &out); err != nil {
return nil, err
}
return out.Items, nil
}
// do issues an authenticated HTTP request. Empty body is OK (GET/DELETE);
// non-nil out triggers JSON decode. Non-2xx responses unmarshal the
// error envelope and return it as a typed error.
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
u := *c.base
u.Path += path
if len(query) > 0 {
u.RawQuery = query.Encode()
}
var reqBody io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("client: marshal body: %w", err)
}
reqBody = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
if err != nil {
return fmt.Errorf("client: new request: %w", err)
}
if reqBody != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("client: do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return &APIError{
Status: resp.StatusCode,
Body: string(raw),
}
}
if out == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("client: decode: %w", err)
}
return nil
}
// APIError is returned for non-2xx HTTP responses. Routes layer encodes
// a JSON error envelope; this preserves the raw body for callers that
// want to inspect provider-specific failure detail.
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("notify: api error status=%d body=%s", e.Status, e.Body)
}