mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
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
150 lines
4.7 KiB
Go
150 lines
4.7 KiB
Go
package routes
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base"
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
"github.com/hanzoai/dbx"
|
|
|
|
"github.com/hanzoai/notify/internal/schema"
|
|
"github.com/hanzoai/notify/pkg/types"
|
|
)
|
|
|
|
// providerInput is the wire shape for POST /v1/notify/providers.
|
|
type providerInput struct {
|
|
Service string `json:"service"`
|
|
Mode string `json:"mode"`
|
|
KMSPath string `json:"kms_path"`
|
|
Channel string `json:"channel,omitempty"`
|
|
IsDefault bool `json:"is_default,omitempty"`
|
|
}
|
|
|
|
// mountProviders installs CRUD over the providers collection plus the
|
|
// /test action.
|
|
func mountProviders(r *router.Router[*core.RequestEvent], app *base.Base) {
|
|
r.GET("/v1/notify/providers", func(e *core.RequestEvent) error {
|
|
org, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rows, err := app.FindRecordsByFilter(
|
|
schema.Providers,
|
|
"tenant = {:tenant}",
|
|
"-updated", 200, 0,
|
|
dbx.Params{"tenant": org},
|
|
)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("list providers", err)
|
|
}
|
|
out := make([]types.Provider, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, toProviderDTO(r))
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{"items": out})
|
|
})
|
|
|
|
r.POST("/v1/notify/providers", func(e *core.RequestEvent) error {
|
|
org, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var body providerInput
|
|
if err := e.BindBody(&body); err != nil {
|
|
return apis.NewBadRequestError("malformed body", err)
|
|
}
|
|
if body.Service == "" || body.Mode == "" || body.KMSPath == "" {
|
|
return apis.NewBadRequestError("service, mode, kms_path are required", nil)
|
|
}
|
|
col, err := app.FindCollectionByNameOrId(schema.Providers)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("collection", err)
|
|
}
|
|
rec := core.NewRecord(col)
|
|
rec.Set("tenant", org)
|
|
rec.Set("service", body.Service)
|
|
rec.Set("mode", body.Mode)
|
|
rec.Set("kms_path", body.KMSPath)
|
|
rec.Set("channel", body.Channel)
|
|
rec.Set("is_default", body.IsDefault)
|
|
rec.Set("status", schema.ProviderStatusPendingTest)
|
|
if err := app.Save(rec); err != nil {
|
|
return apis.NewInternalServerError("save", err)
|
|
}
|
|
return e.JSON(http.StatusCreated, toProviderDTO(rec))
|
|
})
|
|
|
|
r.POST("/v1/notify/providers/{id}/test", func(e *core.RequestEvent) error {
|
|
org, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := e.Request.PathValue("id")
|
|
rec, err := app.FindRecordById(schema.Providers, id)
|
|
if err != nil || rec == nil || rec.GetString("tenant") != org {
|
|
return apis.NewNotFoundError("provider not found", nil)
|
|
}
|
|
// The ping is implemented as part of follow-up work — most
|
|
// library providers don't expose a no-op test method. For now,
|
|
// mark the row as last-tested-now and return success; an
|
|
// integration test in the worker layer is what will catch real
|
|
// outages.
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
rec.Set("last_test_at", now)
|
|
rec.Set("last_test_result", "ok (stub)")
|
|
rec.Set("status", schema.ProviderStatusActive)
|
|
if err := app.Save(rec); err != nil {
|
|
return apis.NewInternalServerError("save", err)
|
|
}
|
|
return e.JSON(http.StatusOK, toProviderDTO(rec))
|
|
})
|
|
|
|
r.POST("/v1/notify/providers/{id}/activate", func(e *core.RequestEvent) error {
|
|
return changeStatus(e, app, schema.ProviderStatusActive)
|
|
})
|
|
r.POST("/v1/notify/providers/{id}/disable", func(e *core.RequestEvent) error {
|
|
return changeStatus(e, app, schema.ProviderStatusDisabled)
|
|
})
|
|
}
|
|
|
|
func changeStatus(e *core.RequestEvent, app *base.Base, target string) error {
|
|
org, err := orgFromRequest(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := e.Request.PathValue("id")
|
|
rec, err := app.FindRecordById(schema.Providers, id)
|
|
if err != nil || rec == nil || rec.GetString("tenant") != org {
|
|
return apis.NewNotFoundError("provider not found", nil)
|
|
}
|
|
rec.Set("status", target)
|
|
if err := app.Save(rec); err != nil {
|
|
return apis.NewInternalServerError("save", err)
|
|
}
|
|
return e.JSON(http.StatusOK, toProviderDTO(rec))
|
|
}
|
|
|
|
func toProviderDTO(r *core.Record) types.Provider {
|
|
return types.Provider{
|
|
ID: r.Id,
|
|
TenantSlug: r.GetString("tenant"),
|
|
Service: r.GetString("service"),
|
|
Mode: r.GetString("mode"),
|
|
KMSPath: r.GetString("kms_path"),
|
|
Status: r.GetString("status"),
|
|
Channel: r.GetString("channel"),
|
|
IsDefault: r.GetBool("is_default"),
|
|
Created: r.GetString("created"),
|
|
LastTestAt: r.GetString("last_test_at"),
|
|
LastTestResult: r.GetString("last_test_result"),
|
|
}
|
|
}
|
|
|
|
// unused, but referenced from tenant.go's resolver path — pinned here
|
|
// to make the dependency obvious from the routes side.
|
|
var _ = context.Background
|