notify

package
v1.12.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 6 Imported by: 0

README

Shared inbox

helpers/notify describes general notices and actionable items without importing an application, router, identity provider or database. helpers/notify/postgres stores them on the host's PostgreSQL pool. AuthKit, OpenRails, ContentKit and their hosts can use the same types; adopting this package does not require any of them to invent notifications.

General notices, such as a comment reply or a receipt, have an empty Status. Tracked actionable items, such as an operator finding, have open, resolved or dismissed status. Read and archive state are independent: reading a failed-payment notice does not fix the payment. Transition only records workflow state; the application authorizes and executes the real action. TransitionTx can commit that action and its resolution atomically. Revision checks reject stale actions.

ActionRequested preserves classification separately from workflow. A historical "payment failed" notice can record that action was requested, with an empty Status, without falsely claiming that the payment still fails today. Imports of such history retain the flag and can display "Action requested"; only an authoritative workflow should display "Open" or "Needs attention". Publishing a tracked StatusOpen item sets the flag automatically. Reading never changes either value.

The host controls which categories produce notices. Routine charge reminders/receipts can be off by default without suppressing failed-payment notices. Preferences and rendering are domain policy, so there is no hard-coded event taxonomy, email template or automatic sender in this package.

// The host creates the schema and runs migrations before accepting requests.
err := postgres.Migrate(ctx, pool, "app")
inbox, err := postgres.New(pool, postgres.Config{
    Schema: "app", Scope: "project:example",
})
item, err := inbox.Publish(ctx, notify.Input{
    Recipient: notify.Recipient{Audience: notify.AudienceUser, ID: userID},
    Origin: notify.Origin{System: "contentkit", Scope: projectID, ID: replyID},
    Kind: "comment.reply", Data: replyReferences, CreatedAt: createdAt,
})

Use PublishTx inside a domain mutation's pgx.Tx when both share the database. A rolled-back comment creates no inbox item. A remote importer instead publishes after fetching the source; retries retain the first payload and local state. Deduplication includes the local scope, audience, recipient and source system/scope/ID, permitting deliberate fan-out while isolating independent merchants and deployments. The host must explicitly map a verified source recipient to a local recipient. Matching email addresses grants no authority.

List returns active receipts by default; ListOptions.Dismissed selects archived receipts. SetRead and SetDismissed return the canonical item and revision. Neither operation acknowledges the producer or changes workflow state. Get includes archived items. EraseRecipient takes the same transactional fence as publishing, deletes the inbox and rejects later publications for that recipient. It is permanent account erasure, not dismissal or unsubscribe. Hosts own retention policy and must never purge open work merely because it was read.

Hosts that combine several verified identities use ListRecipients, GetRecipients, SetReadRecipients and SetDismissedRecipients. Pass the freshly authorized recipients on every request. SQL filters this set before pagination and mutation; unlinking an identity removes its items from access immediately on the next authorization check. Cursors bind the normalized recipient set, scope and filters. Restart pagination after access changes. Inbox cursors are not durable source-delivery watermarks.

Origin.Subject optionally preserves the producer's recipient when the host maps it to a different local identity. It is provenance, not permission, and does not change deduplication identity. A producer retry preserves the original payload, source subject, read/archive state and workflow transitions.

OpenRails produces immutable billing notification events; ContentKit and OpenRails SaaS each persist their own inbox using this package. Each application registers its own source consumer, publishes fetched events idempotently, and acknowledges only successful publications or deliberate policy skips. A crash between local commit and source acknowledgement safely replays the same item. Optional routine billing receipts can be skipped by default without suppressing failures. Source delivery acknowledgements, recipient read/archive state and domain resolution are separate operations.

Existing domain records can also project into the common model without duplicating authoritative workflow storage. Operator findings retain their evidence, severity, reopen rules and resolution executor. Revision: 0 denotes an unversioned external projection: call the source's authorized domain API to act on it. A historical event's ActionRequested flag does not imply that its problem remains unresolved.

The package creates no routes, workers or automatic delivery channels. AuthKit's existing event hook and application-specific producers can publish stable event IDs when a host chooses a concrete recipient mapping. Applications own authentication, preferences, rendering and their existing job-runner lifecycle.

Run the real persistence checks against an owned PostgreSQL database:

NOTIFY_TEST_DATABASE_URL='postgres://…' go test -race ./notify/...

The tests also accept RIVER_TEST_DATABASE_URL, used by the repository's ordinary check script. Each test creates and removes only its own schema. No application or external service dependencies are added.

Documentation

Overview

Package notify defines notices and actionable inbox items shared by host applications. Authentication, rendering, delivery channels and the action that remedies a problem belong to the application. Reading never resolves an item. Existing domain records can be projected as Items without copying their authoritative state into another queue.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrInvalid         = errors.New("notify: invalid input")
	ErrNotFound        = errors.New("notify: item not found")
	ErrConflict        = errors.New("notify: item changed")
	ErrRecipientErased = errors.New("notify: recipient erased")
)

Errors are stable outcomes of validation, scoped lookup and concurrent edits.

Functions

func ValidateRecipient

func ValidateRecipient(r Recipient) error

ValidateRecipient checks the scope-local identity; it grants no authority.

Types

type Audience

type Audience string

Audience selects an authorization boundary, not a delivery channel.

const (
	AudienceUser     Audience = "user"
	AudienceOperator Audience = "operator"
)

type Input

type Input struct {
	Recipient Recipient       `json:"recipient"`
	Origin    Origin          `json:"origin"`
	Kind      string          `json:"kind"`
	Data      json.RawMessage `json:"data"`
	CreatedAt time.Time       `json:"created_at"`
	Status    Status          `json:"status,omitempty"`
	// ActionRequested records the item's category at creation. With empty Status
	// it is historical information, not a claim that the issue is still open.
	ActionRequested bool   `json:"action_requested,omitempty"`
	Severity        string `json:"severity,omitempty"`
}

Input is trusted application-authored content. Kind is extensible. Data must contain only fields the recipient may see. CreatedAt is the source time. Publish accepts StatusNotice or StatusOpen; workflow transitions are separate.

func Validate

func Validate(in Input) (Input, error)

Validate checks a publish input and supplies an empty JSON object when omitted.

type Item

type Item struct {
	ID string `json:"id"`
	Input
	ReadAt *time.Time `json:"read_at"`
	// DismissedAt archives this recipient's receipt without resolving work.
	DismissedAt *time.Time `json:"dismissed_at"`
	ResolvedAt  *time.Time `json:"resolved_at"`
	UpdatedAt   time.Time  `json:"updated_at"`
	Revision    int64      `json:"revision"`
}

Item is an inbox record or a projection of an existing domain record. ReadAt and ResolvedAt describe different operations; neither implies the other. Revision is positive in the durable store and zero for unversioned projections.

type ListOptions

type ListOptions struct {
	Limit      int
	Cursor     string
	UnreadOnly bool
	Status     *Status
	// Dismissed selects archived receipts instead of the active inbox.
	Dismissed bool
}

ListOptions selects a bounded, newest-first page. Status nil includes notices and work items; a pointer to StatusNotice selects only general notices.

type Origin

type Origin struct {
	System string `json:"system"`
	Scope  string `json:"scope"`
	ID     string `json:"id"`
	// Subject is the source recipient, when the destination maps it to a
	// different local identity. It grants no authority by itself.
	Subject string `json:"subject,omitempty"`
}

Origin preserves a producer's deployment, namespace and stable item ID. Scope can identify a merchant, project or account issuer. Each recipient receives at most one item for the same System, Scope and ID in one local store scope; Subject is optional provenance, not part of that identity.

type Page

type Page struct {
	Items      []Item `json:"items"`
	NextCursor string `json:"next_cursor,omitempty"`
}

Page is a bounded page; NextCursor is empty at the end of a current scan.

type Publisher

type Publisher interface {
	Publish(context.Context, Input) (Item, error)
}

Publisher is implemented by durable stores and application adapters. A retry with the same recipient and origin preserves the original content/read state.

type Reader

type Reader interface {
	List(context.Context, Recipient, ListOptions) (Page, error)
}

Reader is a recipient-scoped inbox. Callers supply an authorized recipient.

type Recipient

type Recipient struct {
	Audience Audience `json:"audience"`
	ID       string   `json:"id"`
}

Recipient names a user or an operator queue within the store's scope. Hosts authorize this pair; an email address is never an identity join.

type Status

type Status string

Status is independent of read state. A general notice has no workflow; actionable items begin open and are explicitly resolved or dismissed.

const (
	StatusNotice    Status = ""
	StatusOpen      Status = "open"
	StatusResolved  Status = "resolved"
	StatusDismissed Status = "dismissed"
)

Directories

Path Synopsis
Package postgres implements notify's durable inbox on a host-owned PostgreSQL pool.
Package postgres implements notify's durable inbox on a host-owned PostgreSQL pool.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL