Skip to content
Notifications

Notifications

A notification is a row in the database addressed to one panel account, and a bell in the header that shows it. Nothing else: no queue, no mail, no push. It is the equivalent of Laravel’s database channel and its unreadNotifications, with the bell already built.

err := app.Notify(ctx, user.ID, steward.Notification{
    Title: "Article awaiting review",
    Body:  `"River restoration begins" was submitted by Editor.`,
    URL:   "/app/posts/4182",
    Icon:  "file-text",
    Type:  "post.submitted",
})

That is one INSERT. The account sees the count on its next poll — within a minute — and the row when it opens the bell.

What a notification carries

Titlerequired; the line the bell shows
Bodyoptional second line, clamped to two lines in the bell
URLwhere the row leads. A path on this panel — an absolute URL is ignored
Icona sprite name; an unknown one renders nothing
Typeyour own label, indexed, for finding a group of them later. The framework sends export.ready and export.failed of its own
Dataa JSON payload the panel never reads. Set it with WithPayload

ID, UserID, ReadAt and CreatedAt are set by the call, so leave them alone — whatever you put there is overwritten.

The payload is for the code that reads the notification back, not for display:

type submitted struct {
    PostID uint   `json:"post_id"`
    Author string `json:"author"`
}

n := steward.Notification{Title: "Article awaiting review"}.
    WithPayload(submitted{PostID: 4182, Author: "editor"})

// and later
var s submitted
if err := got.Payload(&s); err != nil {
    return err
}

Sending

// one account
err := app.Notify(ctx, userID, n)

// several, in one statement; duplicate and zero IDs are dropped
err := app.NotifyUsers(ctx, []uint{4, 9, 12}, n)

// everyone holding any of these roles
err := app.NotifyRole(ctx, n, "editor", "publisher")

NotifyRole does not treat the administrator role as implicit. It short-circuits permission checks, not delivery, so notifying editor does not reach an administrator who is not one — name both roles if you mean both.

The natural place to send from is a resource hook, which runs after the row is committed:

steward.Register[Post](app).
    Form(func(f *steward.Form[Post]) {
        f.Saved(func(c *steward.Context, p *Post, created bool) error {
            if p.Status != statusPending {
                return nil
            }
            return c.Panel.NotifyRole(c.Ctx(), steward.Notification{
                Title: "Article awaiting review",
                Body:  p.Title,
                URL:   c.URL("posts", fmt.Sprint(p.ID)),
                Icon:  "file-text",
                Type:  "post.submitted",
            }, "editor")
        })
    })

Build the URL with c.URL, not by writing /app/...: the panel’s prefix is configurable, and a hard-coded one breaks the moment it moves.

Important

A failed notification should rarely fail the action that caused it. In a Saved hook, returning the error rolls nothing back — the row is already committed — but it does show the user an error for something that did save. Log it instead when the notification is incidental.

Reading

items, err := app.Notifications(ctx, userID, 20)     // unread first, newest first
n, err := app.UnreadNotifications(ctx, userID)       // the badge's number

err := app.MarkNotificationRead(ctx, userID, id)     // one
err := app.MarkNotificationsRead(ctx, userID)        // all of this account's
err := app.DeleteNotification(ctx, userID, id)

Every one of these takes the account ID and puts it in the statement, so one account can never read or mark another’s — including through the endpoints the bell uses.

A limit of 0 means the bell’s own: the fifteen most recent.

The bell

It needs no configuration and no permission. The badge polls once a minute; the list is fetched the first time the bell is opened. A row with a URL is a plain link that marks itself read on the way through; a row without one gets a tick to mark it read. Mark all read clears the badge.

Because it is a poll rather than a live connection, a notification appears within a minute of being written — not instantly. That is deliberate: a panel with a hundred open tabs costs a hundred indexed counts a minute, and nothing here needs to be faster.

To leave the control out entirely:

steward.Config{DisableNotifications: true}

The table is still created, so turning it back on later needs no migration.

The whole history

The bell holds fifteen. Everything an account has ever been sent is at /auth/notifications, linked from the foot of the bell: paged fifty at a time, newest first, with an unread-only view and per-row controls to mark one read or delete it. It needs no configuration and no permission, and like the bell it can only ever show the signed-in account’s own rows.

That page is why nothing here pages: a bell that scrolled forever would be a worse version of it.

Housekeeping

The table grows until something trims it. Nothing calls this for you:

gone, err := app.PruneNotifications(ctx, 90*24*time.Hour)

That deletes read notifications older than the age given and returns how many went. Unread ones are kept however old, on the grounds that nobody has seen them yet. Run it from a cron entry, or from the app’s own scheduler.

The table

admin_notifications, created by the framework’s 0005 migration and named with TablePrefix like the rest. If you run migrations explicitly rather than at boot, it arrives with the next migrate up.

id  user_id  type  title  body  url  icon  data  read_at  created_at

(user_id, read_at) is indexed together, because every query the bell makes is “this account’s, unread first”.

What this is not

  • Not a mail or SMS channel. One store, the database. Sending mail on the same event is your code’s job, next to the Notify call.
  • Not realtime. A poll, as above.
  • Not per-user preferences. There is no “which notifications do I want” table; if a panel needs that, the check belongs before the Notify call.
  • Not addressable to anything but a panel account. Laravel’s notifiables can be any model; here the recipient is an admin_users row, because the bell has to render for someone who can sign in.