Documentation
¶
Overview ¶
Package notifications delivers one short message to one person over the channels that person can be reached on.
The shape ¶
A Notification says what it is (a Key) and where it goes (Via). A Notifiable says how to reach somebody on a channel (RouteFor). A Channel does the delivering. The Notifier puts the three together:
n := notifications.New([]notifications.Channel{
channels.NewMail(mailer),
channels.NewDatabase(store),
})
g, err := auth.Authorize(ctx, policy, subject, notifications.ActionSend, user)
if err != nil {
return err
}
err = n.Send(ctx, g, user, InvoicePaid{Number: "2026-114"})
The Grant is not decoration. A notification names a person and usually carries a fact about their account, and the stored copy is a row like any other row -- so it is reachable only through a Policy, on the way in and on the way out.
A model gets both halves by embedding RoutesNotifications and HasDatabaseNotifications.
The interface and the state ¶
Notification is the interface a notification satisfies -- two methods, checked by the compiler -- and NotificationBase is the state it embeds, an id and a locale. A notification that needs neither embeds nothing.
Sending later, and drawing a body ¶
Nothing queues itself: sending on a queue is SendQueuedNotifications, pushed at the call site like any other job, so a call that blocks for two seconds looks different from one that does not.
A mail notification carries structured lines and an action rather than a body. messages.Mail.Render draws the HTML from them and messages.Mail.PlainText the text, and the two cannot disagree. A message that names a template hands it to the view layer, which is a name and not an asset pipeline.
The channels an application has are the slice passed to New, never a driver name resolved from configuration at send time: a channel resolved by string is a channel a notification can name by accident.
Testing ¶
Capture is what a test sends through, and it is passed in rather than installed:
chans, sent := notifications.Capture(notifications.ChannelMail)
n := notifications.New(chans)
payInvoice(ctx, g, n)
if !sent.Sent("billing.invoice-paid", customer) {
t.Fatal("the customer was not told the invoice was paid")
}
The channels are a slice the test owns and Deliveries is the recording it holds, so two tests running in parallel record into two different ones and neither can see the other's. Capture with no arguments takes the three channels this package implements, which answers "was anything sent at all".
Index ¶
- Constants
- Variables
- func Capture(names ...ChannelName) ([]Channel, *Deliveries)
- func Migrations() []migrations.Migration
- func ScopeRead() string
- func ScopeUnread() string
- type Anonymous
- func (a *Anonymous) Channels() []ChannelName
- func (a *Anonymous) GetKey() string
- func (a *Anonymous) NotifiableID() string
- func (a *Anonymous) NotifiableType() string
- func (a *Anonymous) Notify(ctx context.Context, g auth.Grant, n Notification) error
- func (a *Anonymous) NotifyNow(ctx context.Context, g auth.Grant, n Notification, channels ...ChannelName) error
- func (a *Anonymous) Route(c ChannelName, to string) *Anonymous
- func (a *Anonymous) RouteFor(c ChannelName) string
- func (a *Anonymous) RouteNotificationFor(c ChannelName) string
- type Broadcastable
- type Channel
- type ChannelName
- type CreateNotificationsTable
- type Deliveries
- type Delivery
- type EventRecorder
- type HasDatabaseNotifications
- func (h HasDatabaseNotifications) Notifications(ctx context.Context, g auth.Grant, to Notifiable, limit int) (Records, error)
- func (h HasDatabaseNotifications) ReadNotifications(ctx context.Context, g auth.Grant, to Notifiable, limit int) (Records, error)
- func (h HasDatabaseNotifications) UnreadNotifications(ctx context.Context, g auth.Grant, to Notifiable, limit int) (Records, error)
- type Identified
- type Key
- type Localized
- type MemoryStore
- func (s *MemoryStore) Delete(_ context.Context, g auth.Grant, id string) error
- func (s *MemoryStore) For(_ context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
- func (s *MemoryStore) MarkAllAsRead(_ context.Context, g auth.Grant, to Notifiable) error
- func (s *MemoryStore) MarkAsRead(_ context.Context, g auth.Grant, id string) error
- func (s *MemoryStore) MarkAsUnread(_ context.Context, g auth.Grant, id string) error
- func (s *MemoryStore) Save(_ context.Context, g auth.Grant, r Record) (Record, error)
- func (s *MemoryStore) Unread(_ context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
- type Notifiable
- type Notification
- type NotificationBase
- type Notifier
- func (n *Notifier) Channel(name ChannelName) (Channel, error)
- func (n *Notifier) Channels() []ChannelName
- func (n *Notifier) DeliverVia(name ChannelName)
- func (n *Notifier) DeliversVia() ChannelName
- func (n *Notifier) GetDefaultDriver() ChannelName
- func (n *Notifier) Locale(locale string) *Notifier
- func (n *Notifier) Send(ctx context.Context, g auth.Grant, to Notifiable, note Notification) error
- func (n *Notifier) SendMany(ctx context.Context, g auth.Grant, to []Notifiable, note Notification) error
- func (n *Notifier) SendNow(ctx context.Context, g auth.Grant, to Notifiable, note Notification, ...) error
- func (n *Notifier) Suppress(keys ...Key)
- func (n *Notifier) Suppressed(k Key) bool
- type Option
- type Policy
- type Record
- type Records
- type RoutesNotifications
- func (r RoutesNotifications) Notify(ctx context.Context, g auth.Grant, to Notifiable, n Notification) error
- func (r RoutesNotifications) NotifyNow(ctx context.Context, g auth.Grant, to Notifiable, n Notification, ...) error
- func (r RoutesNotifications) RouteFor(c ChannelName) string
- func (r RoutesNotifications) RouteNotificationFor(c ChannelName) string
- type SendQueuedNotifications
- func (j SendQueuedNotifications) Backoff() time.Duration
- func (j SendQueuedNotifications) DisplayName() string
- func (j SendQueuedNotifications) Failed(cause error)
- func (j SendQueuedNotifications) Handle(ctx context.Context, g auth.Grant, n *Notifier) error
- func (j SendQueuedNotifications) RetryUntil() time.Time
- type Store
- type TableStore
- func (s *TableStore) Delete(ctx context.Context, g auth.Grant, id string) error
- func (s *TableStore) For(ctx context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
- func (s *TableStore) MarkAllAsRead(ctx context.Context, g auth.Grant, to Notifiable) error
- func (s *TableStore) MarkAsRead(ctx context.Context, g auth.Grant, id string) error
- func (s *TableStore) MarkAsUnread(ctx context.Context, g auth.Grant, id string) error
- func (s *TableStore) Save(ctx context.Context, g auth.Grant, r Record) (Record, error)
- func (s *TableStore) Unread(ctx context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
Constants ¶
const ( // ActionSend is asked before anything is delivered, and again before the // row is written. It is the one a job or a controller authorizes. ActionSend auth.Action = "notification.send" // ActionList is reading somebody's notifications back -- the bell menu. ActionList auth.Action = "notification.list" // ActionRead is marking one, or all of them, as read. ActionRead auth.Action = "notification.read" // ActionDelete is removing one. ActionDelete auth.Action = "notification.delete" )
The actions a Policy decides about. They are the "module.verb" form the rest of the collection uses, so `aru doctor` recognises them.
const DefaultLimit = 50
DefaultLimit is how many notifications a read returns when the caller asks for none. A bell menu shows a screenful; a query with no limit at all is the one that pages in four years of rows on the day somebody opens it.
const MaxLimit = 500
MaxLimit caps what a caller may ask for, for the same reason.
const Table = "notifications"
Table is the name of the table stored notifications live in.
Variables ¶
var ErrAnonymous = errors.New("notifications: the database channel needs a notifiable that exists")
ErrAnonymous is returned by the database channel when it is handed an Anonymous notifiable.
An on-demand notification has nobody to belong to: the row would name a notifiable that does not exist, and nothing could ever read it back.
var ErrNoChannel = errors.New("notifications: no such channel")
ErrNoChannel is returned when a notification asks for a channel the Notifier was not given. It is an error rather than a skip: a notification that names "sms" in a system with no SMS channel is a notification nobody receives, and silence is how that goes unnoticed for a quarter.
var ErrNotAddressed = errors.New("notifications: the notifiable has no route on this channel")
ErrNotAddressed is returned by a channel when the notifiable has no route on it -- no e-mail address for the mail channel, no connection for broadcast.
The Notifier treats it as "this one was not reachable here" and carries on with the other channels, so a user with no e-mail address still gets the row in their bell menu.
Functions ¶
func Capture ¶
func Capture(names ...ChannelName) ([]Channel, *Deliveries)
Capture returns channels that record instead of delivering, and the recording they write to.
chans, sent := notifications.Capture(notifications.ChannelMail)
n := notifications.New(chans)
// ... exercise the code under test ...
if !sent.Sent("billing.invoice-paid", user) {
t.Fatal("the customer was not told the invoice was paid")
}
With no names it captures the three channels this package implements, which is what a test of "was anything sent at all" wants.
The channels are passed in rather than installed somewhere global, so two tests running in parallel record into two different Deliveries and neither can see the other's.
func Migrations ¶
func Migrations() []migrations.Migration
Migrations is the notifications table.
The table belongs to the package that reads it: an application that never uses the database channel does not create it, and one that does adds this to the list it hands the migrator.
func ScopeRead ¶
func ScopeRead() string
ScopeRead is the SQL condition that keeps only the notifications the recipient has read: a condition written once that a statement pastes in.
TableStore uses it, and so does an application writing its own read model over the same table.
func ScopeUnread ¶
func ScopeUnread() string
ScopeUnread is the condition for the notifications still in the bell menu.
Types ¶
type Anonymous ¶
type Anonymous struct {
// Notifier is who Notify and NotifyNow send with. It is handed over rather
// than looked up, which is also what lets a test hand it a Capture.
Notifier *Notifier
// contains filtered or unexported fields
}
Anonymous is a recipient with no row behind it: an address somebody typed into a form, a webhook that has to be told once.
It exists for the notification that goes to a person the system does not have an account for -- the invitation e-mail being the case everybody hits.
func Route ¶
func Route(c ChannelName, to string) *Anonymous
Route starts an anonymous recipient, addressed on one channel.
notifier.Send(ctx, g, notifications.Route(notifications.ChannelMail, addr), Invite{})
Chain it for a second channel. Routing an Anonymous at ChannelDatabase is accepted here and refused by the database channel with ErrAnonymous, because that is where the reason is legible: the row would name nobody.
func Routes ¶
func Routes(to map[ChannelName]string) *Anonymous
Routes starts an anonymous recipient addressed on several channels at once. It is Route for more than one channel.
to := notifications.Routes(map[notifications.ChannelName]string{
notifications.ChannelMail: "ada@example.com",
notifications.ChannelBroadcast: "invoices.42",
})
A "route" here is an address, not a URL pattern: nothing in this package registers an HTTP route, and none of these addresses reaches a repository. An empty or nil map makes a recipient with nowhere to be reached: Anonymous.Channels answers empty, and a notification whose Via reads it goes nowhere.
func (*Anonymous) Channels ¶
func (a *Anonymous) Channels() []ChannelName
Channels is every channel this recipient was routed at, sorted, which is what a Notification's Via can return when it means "wherever this one can be reached". Sorted, so the order does not depend on the map.
func (*Anonymous) GetKey ¶
GetKey is empty, for the same reason. It is why the broadcast channel refuses an anonymous recipient that named no channel of its own: there is no id to build one out of.
func (*Anonymous) NotifiableID ¶
NotifiableID is empty: there is no row.
func (*Anonymous) NotifiableType ¶
NotifiableType is "anonymous", which is what an on-demand recipient is: it never reaches a table, so there is no kind of row to name.
func (*Anonymous) Notify ¶
Notify sends n to this recipient through its own Notifier, and fails when it has none. An Anonymous is itself the notifiable, so it hands itself over.
func (*Anonymous) NotifyNow ¶
func (a *Anonymous) NotifyNow(ctx context.Context, g auth.Grant, n Notification, channels ...ChannelName) error
NotifyNow is Anonymous.Notify with a channel override: the channels named here are the ones used, in place of whatever the notification's Via answers.
func (*Anonymous) Route ¶
func (a *Anonymous) Route(c ChannelName, to string) *Anonymous
Route adds an address on another channel and returns the same recipient, so the calls chain.
func (*Anonymous) RouteFor ¶
func (a *Anonymous) RouteFor(c ChannelName) string
RouteFor answers with whatever Route recorded, and the empty string for a channel this recipient was never addressed on.
func (*Anonymous) RouteNotificationFor ¶
func (a *Anonymous) RouteNotificationFor(c ChannelName) string
RouteNotificationFor is Anonymous.RouteFor under its other name, so that a recipient written against either one works.
type Broadcastable ¶
type Broadcastable interface {
BroadcastOn() []string
}
Broadcastable is a notification that names its own broadcast channels.
It is the optional half of NotificationBase: a notification that embeds the base satisfies it, and one that does not is broadcast on the recipient's own channel.
type Channel ¶
type Channel interface {
// Name is what a Notification's Via has to return to reach this channel.
Name() ChannelName
// Send delivers, and answers with a receipt: whatever identifies the
// delivery on the other side -- a provider message id, the id of the row
// that was written. The empty string is fine for a channel that has
// nothing to identify a delivery by.
//
// A channel that cannot reach this recipient returns ErrNotAddressed, and
// the Notifier moves on to the next channel rather than failing the send:
// a user with no e-mail address still gets the row in their bell menu.
Send(ctx context.Context, g auth.Grant, to Notifiable, n Notification) (string, error)
}
Channel delivers a notification one way.
Naming the two methods is what turns "this channel cannot deliver" into a compile error rather than something the Notifier discovers at the send.
Writing one is small on purpose: everything above it -- authorization, the choice of channels, suppression, the events -- has already happened, so a channel is "turn this notification into the shape my transport wants, and hand it over".
type ChannelName ¶
type ChannelName string
ChannelName is which way a notification travels.
const ( // ChannelMail is e-mail, delivered by hesape/notifications/channels.Mail. ChannelMail ChannelName = "mail" // ChannelDatabase is a row in the notifications table, for the bell menu. ChannelDatabase ChannelName = "database" // ChannelBroadcast is a live push to a connected browser. ChannelBroadcast ChannelName = "broadcast" )
The channels that ship with the collection. A project may declare its own -- a ChannelName is a string, and Notifier looks the name up in the slice it was given -- but these three are the ones the framework itself implements.
type CreateNotificationsTable ¶ added in v0.5.0
type CreateNotificationsTable struct{ migrations.BaseMigration }
CreateNotificationsTable creates the table TableStore reads and writes.
It is code rather than a file in a tree. notifications/console.NotificationTableCommand is the command that writes it out for a project that generates rather than imports.
func (CreateNotificationsTable) Down ¶ added in v0.5.0
func (CreateNotificationsTable) Down(ctx context.Context, conn migrations.Connection) error
Down drops the notifications table, and the index with it.
func (CreateNotificationsTable) GetName ¶ added in v0.5.0
func (CreateNotificationsTable) GetName() string
GetName returns the migration's name.
func (CreateNotificationsTable) Up ¶ added in v0.5.0
func (CreateNotificationsTable) Up(ctx context.Context, conn migrations.Connection) error
Up creates the notifications table and the index every read of it uses.
The key column is notification_key and not key: KEY is reserved in MySQL, and a table nobody can create on one of the three supported databases is a table that fails on the day somebody switches.
type Deliveries ¶
type Deliveries struct {
// contains filtered or unexported fields
}
Deliveries is what Capture records into.
func (*Deliveries) All ¶
func (d *Deliveries) All() []Delivery
All returns a copy of everything recorded, in the order it happened. It is a copy, so a caller reading it cannot race a send still in flight.
func (*Deliveries) For ¶
func (d *Deliveries) For(k Key) []Delivery
For returns the deliveries of one kind of notification, across every channel.
func (*Deliveries) Reset ¶
func (d *Deliveries) Reset()
Reset forgets everything, for a test that reuses one Notifier across cases.
func (*Deliveries) Sent ¶
func (d *Deliveries) Sent(k Key, to Notifiable) bool
Sent reports whether a kind of notification reached a recipient on any channel. It is the assertion a test writes nine times out of ten.
A nil recipient is false rather than a match on everything.
type Delivery ¶
type Delivery struct {
// Channel is which one took it.
Channel ChannelName
// Key is the kind, which is what an assertion usually names.
Key Key
// Notification is the value itself, so a test can look at its fields
// without the channel having had to guess what a test would want.
Notification Notification
// To is the recipient it was addressed to.
To Notifiable
// Route is what the recipient answered RouteFor with: the e-mail address,
// the broadcast channel.
Route string
// Tenant is the tenant off the Grant that authorized the send.
Tenant string
}
Delivery is one notification handed to one channel, as a capturing channel recorded it.
type EventRecorder ¶
EventRecorder is where the Notifier reports what it did.
It names the one method it needs rather than taking *events.Recorder, so a test can watch the three events without an outbox and a database behind it.
type HasDatabaseNotifications ¶
type HasDatabaseNotifications struct {
// Store is where the rows are.
Store Store
}
HasDatabaseNotifications is a recipient's bell menu.
Its three reads each take a Grant and are scoped by its tenant, because a bell menu is somebody's invoices and somebody else's mentions.
func (HasDatabaseNotifications) Notifications ¶
func (h HasDatabaseNotifications) Notifications(ctx context.Context, g auth.Grant, to Notifiable, limit int) (Records, error)
Notifications is the recipient's notifications, newest first.
The page size is an argument rather than the caller's to decide afterwards, because a read nobody limited is the query that pages in four years of rows.
func (HasDatabaseNotifications) ReadNotifications ¶
func (h HasDatabaseNotifications) ReadNotifications(ctx context.Context, g auth.Grant, to Notifiable, limit int) (Records, error)
ReadNotifications is the ones the recipient has already read.
The filter runs over what Notifications returned, so it reads a page at a time like the other two: a recipient with ten thousand read notifications is a recipient whose caller should be paging rather than asking for all of them.
func (HasDatabaseNotifications) UnreadNotifications ¶
func (h HasDatabaseNotifications) UnreadNotifications(ctx context.Context, g auth.Grant, to Notifiable, limit int) (Records, error)
UnreadNotifications is the ones still in the bell menu, newest first.
type Identified ¶
type Identified interface {
NotificationID() string
}
Identified is a notification that carries the id of its delivery.
It is the optional half of NotificationBase: a notification that embeds the base satisfies it, and one that does not is delivered without an id.
type Key ¶
type Key string
Key is the stable name of a kind of notification: "auth.password-reset", "billing.invoice-paid".
It is a name of its own rather than the type name, so that the Go type behind it can be renamed, moved or split without touching a single stored row. A type name in the type column would mean every row already written says something that no longer exists.
It is also what Suppress silences and what a test asserts on, both of which want a name rather than a type.
func (Key) Valid ¶
Valid reports whether a Key is one: lowercase, dotted, no spaces.
It is checked before anything is stored because the Key is written to a column that is filtered on and read back by name, and a key with a stray space in it is a key that matches nothing, forever, with no error anywhere.
type Localized ¶
type Localized interface {
// PreferredLocale is a BCP 47 tag: "pt-BR", "en". The empty string means
// the recipient has no preference and the application default stands.
PreferredLocale() string
}
Localized is the optional half of Notifiable: a recipient who has a language.
A channel that renders words -- mail today -- reads it and carries the locale on the message, so the body is drawn in the language the person chose rather than in the language of whoever triggered the send.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore keeps notifications in memory.
It is the store a test uses and the store a single-process tool uses, in one type: a second "fake" implementation next to a real one is two things to keep in step, and the one the tests use is the one that drifts.
It enforces the same Grant and the same tenant scoping as the table, which is the point -- a test that passes against a store with no authorization proves nothing about the code that runs in production.
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an empty store that keeps its rows for the life of the process.
func (*MemoryStore) For ¶
func (s *MemoryStore) For(_ context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
For returns the most recent notifications for a recipient, newest first. It is HasDatabaseNotifications::notifications, executed.
func (*MemoryStore) MarkAllAsRead ¶
func (s *MemoryStore) MarkAllAsRead(_ context.Context, g auth.Grant, to Notifiable) error
MarkAllAsRead stamps every unread notification a recipient has. It is DatabaseNotificationCollection::markAsRead over the whole relation, as one statement rather than a row at a time.
func (*MemoryStore) MarkAsRead ¶
MarkAsRead stamps one. It is DatabaseNotification::markAsRead, executed.
func (*MemoryStore) MarkAsUnread ¶
MarkAsUnread clears the stamp on one. It is DatabaseNotification::markAsUnread, executed.
type Notifiable ¶
type Notifiable interface {
// NotifiableID is the primary key of the row being notified.
NotifiableID() string
// NotifiableType names what kind of row it is: "user", "team".
//
// It is stored next to the id because the table holds notifications for
// every kind of notifiable, and an id alone does not say which table it
// came from.
NotifiableType() string
// RouteFor is the address on a channel: an e-mail address for ChannelMail,
// a channel name for ChannelBroadcast. The empty string means "not
// reachable there", and the channel is skipped rather than failing.
RouteFor(c ChannelName) string
}
Notifiable is somebody a notification can reach.
It is three methods rather than a routing method found by name at send time, so a type that cannot be notified does not compile at the call rather than answering nothing at midnight.
type Notification ¶
type Notification interface {
// Key names the kind. It is written to the type column and is what
// Suppress matches on.
Key() Key
// Via is which channels this notification takes for this recipient.
//
// The recipient is an argument because the answer depends on them: a user
// who has turned e-mail off gets the database row and nothing else, and
// that decision belongs next to the notification rather than in a filter
// somewhere downstream.
Via(to Notifiable) []ChannelName
}
Notification is one thing an application has to tell somebody.
The two methods are all the Notifier needs. What a notification looks like on a given channel is an optional interface satisfied per channel -- ToMail on the mail channel, ToDatabase on the database channel -- so a notification that never goes by e-mail does not carry an empty ToMail, and adding a channel to a project does not touch the notifications that do not use it.
type NotificationBase ¶
type NotificationBase struct {
// ID is the identifier of this delivery. It is written to the stored row
// and travels to every channel, so the same notification sent over mail and
// over the database can be matched up in a support ticket.
ID string
// LocaleName is the language to render in: "pt-BR", "en".
//
// The fields and the methods of a Go type share one namespace, so the
// setter keeps the short name -- Locale is what somebody types -- and the
// field says what it holds. PreferredLocale is what reads it.
LocaleName string
}
NotificationBase is the state every notification carries whatever channel it takes: the id of the delivery and the language to render it in.
A notification gets it by embedding it:
type InvoicePaid struct {
notifications.NotificationBase
Number string
}
The name carries "Base" because Notification is already the interface a notification satisfies.
func (NotificationBase) BroadcastOn ¶
func (NotificationBase) BroadcastOn() []string
BroadcastOn is Notification::broadcastOn.
Empty is the default and means "the recipient's own private channel", which the broadcast channel derives from the notifiable. A notification that must go somewhere else -- a shared team channel, a public status feed -- overrides it.
func (NotificationBase) Locale ¶
func (n NotificationBase) Locale(locale string) NotificationBase
Locale sets the language this notification is sent in, and returns a copy.
It beats the recipient's own preference: a notification that has been told which language to use has been told for a reason, usually because it is about something the sender chose the words for.
func (NotificationBase) NotificationID ¶
func (n NotificationBase) NotificationID() string
NotificationID is the id of this delivery, and it is what ties the copy pushed to a browser to the copy stored in the bell menu.
func (NotificationBase) PreferredLocale ¶
func (n NotificationBase) PreferredLocale() string
PreferredLocale is the language the notification asked for, or the empty string when it asked for none.
It is the read of Notification::$locale, which NotificationSender::preferredLocale does inline.
It satisfies Localized, which is the one question the Notifier asks about a language whether it is asking a notification or a recipient.
type Notifier ¶
type Notifier struct {
// contains filtered or unexported fields
}
Notifier sends a Notification to a Notifiable over the channels it was given.
There is no driver to resolve from configuration: the channels an application has are the slice passed to New, and a channel that is not in the slice is a channel a notification cannot name by accident.
func New ¶
New returns a Notifier that can reach the given channels. A nil channel in the slice is skipped.
Two channels answering to the same name is a configuration mistake that would otherwise show up as "half the notifications went to the wrong place": the last one wins here, and Channels reports what is actually wired.
func (*Notifier) Channel ¶
func (n *Notifier) Channel(name ChannelName) (Channel, error)
Channel is the channel wired under a name.
An empty name returns the default one. A name nothing answers to is ErrNoChannel rather than a nil Channel, because a nil Channel is a panic two frames later.
func (*Notifier) Channels ¶
func (n *Notifier) Channels() []ChannelName
Channels is which channel names are wired, sorted, for a diagnostic: it is the list of names Notifier.Channel will answer to.
func (*Notifier) DeliverVia ¶
func (n *Notifier) DeliverVia(name ChannelName)
DeliverVia sets the channel used when nothing names one.
func (*Notifier) DeliversVia ¶
func (n *Notifier) DeliversVia() ChannelName
DeliversVia is Notifier.GetDefaultDriver under the name that reads well next to Notifier.DeliverVia.
func (*Notifier) GetDefaultDriver ¶
func (n *Notifier) GetDefaultDriver() ChannelName
GetDefaultDriver is the channel used when nothing names one. It is "mail" until Notifier.DeliverVia says otherwise.
func (*Notifier) Locale ¶
Locale sets the language every notification this Notifier sends is rendered in, whatever the recipient's own preference.
It is for the process that has one answer for all of them: a report generated for an operator, a batch of invoices for one market. A notification that sets its own locale still wins.
func (*Notifier) Send ¶
func (n *Notifier) Send(ctx context.Context, g auth.Grant, to Notifiable, note Notification) error
Send delivers one notification to one recipient, over every channel the notification names for them. A list of recipients is Notifier.SendMany, because a signature that accepts either is a signature that tells you nothing.
A channel that fails does not stop the others: the errors are joined and returned together, so "the mail provider was down" does not also mean "and the row was never written". Failing on the first one is how a transient SMTP failure loses the copy the user would have seen in the morning.
func (*Notifier) SendMany ¶
func (n *Notifier) SendMany(ctx context.Context, g auth.Grant, to []Notifiable, note Notification) error
SendMany is Notifier.Send for a list of recipients.
It keeps going after a recipient fails, for the reason a bulk send exists at all: stopping at the first bad address means the other nine hundred people hear nothing, and nobody finds out until they ask.
func (*Notifier) SendNow ¶
func (n *Notifier) SendNow(ctx context.Context, g auth.Grant, to Notifiable, note Notification, channels ...ChannelName) error
SendNow delivers one notification over the channels given rather than the ones the notification names.
With no channels it is Notifier.Send. With them it is the escape hatch: "this one, over these, whatever the notification usually does" -- the resend button on a support screen, and the retry of one channel that was down when the rest went out.
func (*Notifier) Suppress ¶
Suppress silences a kind of notification for the life of this Notifier.
It is for the process that must not send: an import that touches ten thousand rows, a seeder, a replay of yesterday's queue. It is a list of keys on the object that would do the sending, so the suppression is visible where the sending is.
There is no Unsuppress. A process that suppresses does so because sending would be wrong for the whole of it, and a switch that goes both ways is a switch somebody flips in the middle of a loop.
func (*Notifier) Suppressed ¶
Suppressed reports whether a key is silenced by Notifier.Suppress.
type Option ¶
type Option func(*Notifier)
Option configures a Notifier at construction.
func WithEvents ¶
func WithEvents(r EventRecorder) Option
WithEvents records notification.sending, notification.sent and notification.failed into r.
Without it the Notifier records nothing, which is the right default for a command-line tool and the wrong one for an application: "the customer says they never got it" is answered by these three rows and by nothing else.
type Policy ¶
type Policy struct{}
Policy is the default decision about stored notifications: a subject may read and clear their own, and nobody else's.
It is here rather than in the skeleton because every application wants this same answer and getting it wrong leaks a bell menu. An application with a different rule -- a support agent who may read a customer's -- writes its own Policy and passes that instead; the type is an argument to auth.Authorize, not a registration.
func (Policy) Can ¶
Can decides whether the subject may take an action on a stored notification.
ActionSend is about the sender rather than about a stored row, so it asks only that the subject carry a tenant: the row that gets written is scoped to it, and a send with no tenant has nowhere to be stored.
The three reading actions compare the record against the subject. A collection action passes the zero Record -- there is no row yet to compare -- and the tenant on the Grant is what scopes the query.
type Record ¶
type Record struct {
// ID is the row's own identifier, a UUIDv7 so the table sorts by time
// without a second index to do it.
ID string
// Tenant comes off the Grant, never from the request.
Tenant string
// NotifiableType and NotifiableID are who it is for.
NotifiableType string
NotifiableID string
// Key is the kind, and is what a query filters on: "billing.invoice-paid".
Key Key
// Data is the payload the database channel produced, as JSON. It is what
// the bell menu renders from.
Data json.RawMessage
// ReadAt is when the recipient read it. The zero value means unread, which
// is why Read and Unread exist rather than a *time.Time nobody remembers
// to nil-check.
ReadAt time.Time
// CreatedAt is when it was stored.
CreatedAt time.Time
}
Record is one stored notification: the row behind the bell menu.
It carries a tenant beside the recipient, because in a SaaS a notification belongs to a customer before it belongs to a person, and a query that forgets that reads across all of them.
func (Record) MarkAsRead ¶
MarkAsRead stamps the notification read.
The store is an argument because a Record is a row and not an object with a connection inside it, and the Grant is one because stamping somebody's notification read is a write on their data.
Marking a notification that is already read changes nothing and does not move the timestamp: the recipient wanted it read and it is.
func (Record) MarkAsUnread ¶
MarkAsUnread clears the stamp, so the notification is back in the bell menu.
It is the undo of Record.MarkAsRead: a menu that marks everything read on open needs a way to put one back.
func (Record) Notifiable ¶
func (r Record) Notifiable() Notifiable
Notifiable is who this notification was addressed to: the two columns that name the row -- the type and the id -- as something a channel or a store can be handed.
It does not load the model. That is the application's job: this package does not know what a "user" is and must not.
type Records ¶
type Records []Record
Records is a page of stored notifications.
func (Records) MarkAsRead ¶
MarkAsRead stamps every notification in the page read.
It stops at the first error rather than carrying on, because the errors a store returns here are "no such row" and "the Grant does not allow it", and neither gets better on the next row.
func (Records) MarkAsUnread ¶
MarkAsUnread puts every notification in the page back in the bell menu. It stops at the first error, for the reason Records.MarkAsRead gives.
func (Records) Read ¶
Read is the ones the recipient has read. It is named rather than left to a filter at every call site, because the alternative is the same closure written in every caller.
func (Records) Unread ¶
Unread is the ones they have not, for the reason Records.Read gives.
type RoutesNotifications ¶
type RoutesNotifications struct {
// Notifier is who does the sending.
Notifier *Notifier
// Routes is the address on each channel: an e-mail address for
// ChannelMail, a channel name for ChannelBroadcast.
Routes map[ChannelName]string
}
RoutesNotifications is how a recipient is reached and how it is notified.
A model embeds it:
type User struct {
notifications.RoutesNotifications
ID string
Email string
}
The Notifier is a field rather than something found globally: the model is handed the one it should use, which is also what lets a test hand it a Capture.
func (RoutesNotifications) Notify ¶
func (r RoutesNotifications) Notify(ctx context.Context, g auth.Grant, to Notifiable, n Notification) error
Notify sends n to to through this recipient's own Notifier, and fails when it has none.
The recipient is an argument rather than the receiver because the receiver is the embedded struct and not the model that embeds it: Go has no `$this` that reaches the outer value. `user.Notify(ctx, g, user, InvoicePaid{})` reads oddly once and is honest about it.
func (RoutesNotifications) NotifyNow ¶
func (r RoutesNotifications) NotifyNow(ctx context.Context, g auth.Grant, to Notifiable, n Notification, channels ...ChannelName) error
NotifyNow is RoutesNotifications.Notify with a channel override: the channels named here are the ones used, in place of whatever the notification's Via answers.
func (RoutesNotifications) RouteFor ¶
func (r RoutesNotifications) RouteFor(c ChannelName) string
RouteFor is the address on a channel, or the empty string when the recipient cannot be reached there. It is what satisfies Notifiable.
The routes are a map rather than a method found by name per channel, because a route found by name is a route that silently answers nothing the day somebody renames a method.
func (RoutesNotifications) RouteNotificationFor ¶
func (r RoutesNotifications) RouteNotificationFor(c ChannelName) string
RouteNotificationFor is RoutesNotifications.RouteFor under its other name, so that a recipient written against either one works.
type SendQueuedNotifications ¶
type SendQueuedNotifications struct {
// Notifiables is who to reach. One recipient is a slice of one.
Notifiables []Notifiable
// Notification is what to send.
Notification Notification
// Channels overrides the channels the notification names, or is empty to
// use them.
Channels []ChannelName
// Tries is how many attempts the worker gives it, Timeout is how long one
// attempt may run, and MaxExceptions is how many unhandled failures are
// allowed before the job is given up on. Zero means "whatever the worker is
// configured to do".
Tries int
Timeout time.Duration
MaxExceptions int
// BackoffFor is how long a released notification waits before it is
// available again, and RetryUntilAt is when the worker stops retrying.
//
// The fields and the methods of a Go type share one namespace, so the
// methods keep the short names -- Backoff, RetryUntil -- and the fields say
// what they hold.
BackoffFor time.Duration
RetryUntilAt time.Time
// OnFailure is called when the worker gives up, after Failed has been told.
// It is a function rather than an optional method on the notification,
// because a notification is an interface and every method on an interface
// is one every notification has to write.
OnFailure func(cause error)
}
SendQueuedNotifications is the job that sends a notification from a worker.
It is what "send this later" means here, and nothing decides it for you: the choice is which of the two calls you write, so a call that blocks for two seconds looks different from one that does not.
notifier.Send(ctx, g, user, InvoicePaid{...}) // now
queue.Push(ctx, g, "", job.Name, job.Payload) // later
The value carries the recipients, the notification and the channels. What it does not carry is a serialized model: the recipients are their identities, and a worker that needs more than that loads it.
func (SendQueuedNotifications) Backoff ¶
func (j SendQueuedNotifications) Backoff() time.Duration
Backoff is SendQueuedNotifications::backoff: how long a released notification waits before a worker may take it again. Zero means the worker's own default.
func (SendQueuedNotifications) DisplayName ¶
func (j SendQueuedNotifications) DisplayName() string
DisplayName is what the job is called on a dashboard and in a log line.
It is the Key, which was chosen to be stable, rather than a type name that changes when somebody moves a type between packages.
func (SendQueuedNotifications) Failed ¶
func (j SendQueuedNotifications) Failed(cause error)
Failed is SendQueuedNotifications::failed, called when the worker gives up on the job.
func (SendQueuedNotifications) Handle ¶
Handle sends the notification, and is what the worker calls. It keeps going after a recipient fails and joins the errors.
The Notifier is an argument, along with the ctx the I/O needs and the Grant every send takes.
func (SendQueuedNotifications) RetryUntil ¶
func (j SendQueuedNotifications) RetryUntil() time.Time
RetryUntil is SendQueuedNotifications::retryUntil: when the worker stops retrying. The zero time means the worker's own limit stands.
type Store ¶
type Store interface {
// Save writes one and returns it with the id and timestamp filled in.
Save(ctx context.Context, g auth.Grant, r Record) (Record, error)
// For returns the most recent notifications for a recipient, newest first.
For(ctx context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
// Unread is For, restricted to the ones not yet read.
Unread(ctx context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
// MarkAsRead stamps one. Marking a read notification read again is not an
// error and does not move the timestamp.
MarkAsRead(ctx context.Context, g auth.Grant, id string) error
// MarkAsUnread clears the stamp, putting the notification back in the bell
// menu. Marking an unread notification unread again is not an error.
MarkAsUnread(ctx context.Context, g auth.Grant, id string) error
// MarkAllAsRead stamps every unread one a recipient has.
MarkAllAsRead(ctx context.Context, g auth.Grant, to Notifiable) error
// Delete removes one. A missing row is database.ErrNotFound.
Delete(ctx context.Context, g auth.Grant, id string) error
}
Store is where the database channel puts a notification and where the bell menu reads it back.
Every method takes a Grant, reads included. A notification is somebody's invoice, somebody's password reset, somebody's mention -- a list endpoint without a policy is a list endpoint that hands one tenant another tenant's bell menu.
type TableStore ¶
type TableStore struct {
// contains filtered or unexported fields
}
TableStore is the Store backed by the notifications table.
The SQL is written out and the values go in placeholders. There is no query builder and no ORM behind it: five statements, each of them readable, each of them carrying the tenant in the WHERE clause because that is the clause the whole design rests on.
func NewTableStore ¶
func NewTableStore(db *database.DB) *TableStore
NewTableStore returns a Store over an open connection. The connection is an argument, so a store is never holding one nobody named.
func (*TableStore) For ¶
func (s *TableStore) For(ctx context.Context, g auth.Grant, to Notifiable, limit int) ([]Record, error)
For returns the most recent notifications for a recipient, newest first. It is HasDatabaseNotifications::notifications, executed.
func (*TableStore) MarkAllAsRead ¶
func (s *TableStore) MarkAllAsRead(ctx context.Context, g auth.Grant, to Notifiable) error
MarkAllAsRead stamps every unread notification a recipient has. It is DatabaseNotificationCollection::markAsRead over the whole relation, as one statement rather than a row at a time.
func (*TableStore) MarkAsRead ¶
MarkAsRead stamps one. It is DatabaseNotification::markAsRead, executed.
Which row the Grant was issued for is decided by the Authorize call that produced it; what this statement guarantees is the tenant. A row belonging to another tenant answers database.ErrNotFound rather than "forbidden", because the difference between the two tells the caller the row exists.
func (*TableStore) MarkAsUnread ¶
MarkAsUnread clears the stamp on one. It is DatabaseNotification::markAsUnread, executed.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package channels is the three ways a notification can be delivered: Mail sends it as an e-mail, Database stores it as a row, and Broadcast pushes it to a browser that is connected right now.
|
Package channels is the three ways a notification can be delivered: Mail sends it as an e-mail, Database stores it as a row, and Broadcast pushes it to a browser that is connected right now. |
|
Package console is the one command the notifications component ships.
|
Package console is the one command the notifications component ships. |
|
Package events names the three things that can happen to a notification on its way out, and builds the hesape/events value that records each of them.
|
Package events names the three things that can happen to a notification on its way out, and builds the hesape/events value that records each of them. |
|
Package messages is what a notification looks like on a channel: Mail for an e-mail, Database for a stored row, Broadcast for a live push, and Action for the one button a mail message may carry.
|
Package messages is what a notification looks like on a channel: Mail for an e-mail, Database for a stored row, Broadcast for a live push, and Action for the one button a mail message may carry. |