Files
zeekay 5a75f897d9 notify: wire async send via hanzoai/tasks per CONTRACT.md
POST /v1/notify/send now defaults to durable async dispatch via
hanzoai/tasks (NotifySendWorkflow, queue notify-send) and returns 202
Accepted with {message_id, task_id, status:"queued"}. ?sync=true keeps
the inline path and returns 200 with the terminal result.

The router fails closed if async is requested but the Dispatcher is
nil or not Started (503) — no silent sync fallback, matching
hanzoai/tasks CONTRACT.md §3 ("production MUST set TASKS_ADDR").

Worker lifecycle moves out of routes/ into cmd/notifyd so the routes
package depends only on the new tasks.Dispatcher interface; tests stub
it directly. Tasks namespace defaults to "default" per CONTRACT.md §6
(single shared worker for all tenants in this PR; per-org namespacing
is the next step once hanzo tenant traffic scales).

A handful of internal helpers narrow from *base.Base to the smaller
core.App interface (Activities, metering.Write/Aggregate, tenant.New,
event.Resolve, template.LoadPublished, mountSend) so tests can drive
them with tests.NewTestApp without booting a full daemon.

Tests:
- internal/tasks/activities_test.go — Deliver idempotency
  short-circuits on status=sent and status=failed plus nil-receiver
  guard.
- internal/routes/send_test.go — sync 200 / async 202 happy paths +
  503-on-missing-dispatcher + 503-on-unstarted-dispatcher +
  500-on-dispatch-error + 401-on-missing-X-Org-Id.

References hanzoai/tasks#3.
2026-06-01 21:08:16 -07:00

45 lines
1.2 KiB
Go

// Package event is the catalog layer over the `events` collection.
//
// An Event entry binds (tenant, name) to a default template + routing
// preferences + a rate limit. The Send pipeline reads the catalog
// when SendRequest.Event is set, applies the policy, then proceeds.
//
// No external state — this package is a thin wrapper over base record
// operations to keep the routes layer readable.
package event
import (
"errors"
"fmt"
"github.com/hanzoai/base/core"
"github.com/hanzoai/dbx"
"github.com/hanzoai/notify/internal/schema"
)
// Resolve loads the enabled event row for (tenant, name) and returns
// nil (not an error) when no match exists. Empty name short-circuits.
func Resolve(app core.App, tenant, name string) (*core.Record, error) {
if tenant == "" {
return nil, errors.New("event: tenant is required")
}
if name == "" {
return nil, nil
}
rows, err := app.FindRecordsByFilter(
schema.Events,
"tenant = {:tenant} && name = {:name} && enabled = true",
"",
1, 0,
dbx.Params{"tenant": tenant, "name": name},
)
if err != nil {
return nil, fmt.Errorf("event: lookup: %w", err)
}
if len(rows) == 0 {
return nil, nil
}
return rows[0], nil
}