Documentation
¶
Overview ¶
Package notify delivers in-app notifications.
Scope is deliberately narrow: a per-user inbox its named consumers can write to, and nothing else. There is no push transport, no per-event preference machinery and no general notification centre — no scope row asks for one, and the risk this milestone was flagged for was building one anyway. Email is M26's concern and reads from its own outbox, not from this table.
The table shipped dormant in Phase 1 and this package adds no DDL. Anything structural a kind needs goes in the `data` jsonb, which is the rule every dormant table in this schema follows until the feature that needs a column actually arrives.
Index ¶
- Constants
- func HumanBytes(n int64) string
- type Enqueuer
- type Event
- type Filter
- type Notification
- type Notifier
- type Recipient
- type Service
- func (s *Service) AnnounceRelease(ctx context.Context, running, available string) error
- func (s *Service) AutomationFired(ctx context.Context, orgID uuid.UUID, workspaceID uuid.UUID, ruleID uuid.UUID, ...) error
- func (s *Service) EveryReviewer(ctx context.Context) ([]Recipient, error)
- func (s *Service) Get(ctx context.Context, actor *auth.Identity, id uuid.UUID) (Notification, error)
- func (s *Service) List(ctx context.Context, actor *auth.Identity, f Filter) (*domain.Page[Notification], error)
- func (s *Service) Mail(ctx context.Context, to Recipient, template string, data map[string]string) error
- func (s *Service) MarkAllRead(ctx context.Context, actor *auth.Identity) (int64, error)
- func (s *Service) MarkRead(ctx context.Context, actor *auth.Identity, id uuid.UUID) error
- func (s *Service) MarkUnread(ctx context.Context, actor *auth.Identity, id uuid.UUID) error
- func (s *Service) NotifiedSince(ctx context.Context, userID uuid.UUID, kind string, since time.Time) (bool, error)
- func (s *Service) Notify(ctx context.Context, userID uuid.UUID, e Event) error
- func (s *Service) NotifyMFAChange(ctx context.Context, ev auth.MFAChange) error
- func (s *Service) OwnersOf(ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID) ([]Recipient, error)
- func (s *Service) RecipientByID(ctx context.Context, userID uuid.UUID) (Recipient, error)
- func (s *Service) Unread(ctx context.Context, actor *auth.Identity) (int64, error)
- func (s *Service) UnreadPreview(ctx context.Context, actor *auth.Identity, limit int32) (int64, []Notification, error)
- func (s *Service) WarnAuditGrowth(ctx context.Context, size, threshold int64) error
- func (s *Service) WarnDomainFailing(ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID, ...) error
- func (s *Service) WarnDomainUnverified(ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID, ...) error
- func (s *Service) WithMail(m Enqueuer, appURL string) *Service
Constants ¶
const ( // KindDomainFailing is the warning: this hostname has stopped verifying and // will stop being served at a stated time unless the record comes back. KindDomainFailing = "domain.failing" // KindDomainUnverified is the stop itself. A separate kind rather than a // second message under the first, because they are different facts and an // operator filtering their inbox for "what went dark" should not have to // read the bodies to tell them apart. KindDomainUnverified = "domain.unverified" )
const ( // KindAuditGrowth warns that the audit log has passed its size threshold. // The first consumer, and the one that made this milestone urgent: audit // retention defaults to keeping everything (D5), which is only a safe // default while somebody is told what it costs. KindAuditGrowth = "audit.growth" // KindInviteAccepted tells the person who sent an invitation that it was // redeemed. The organization gained a member, and the one account that // certainly wants to know is the one that chose to add them. KindInviteAccepted = "invite.accepted" // KindMFAChanged tells an account that its second factor changed (M53). // // **One kind for four events, and that is the decision rather than an // economy.** Enrolled, disabled, a recovery code spent, the codes // regenerated — the recipient is the same person and what they do about any // of them is the same thing: open /account and look. Four kinds would be four // entries in internal/httpx's notificationTargets all returning the same // path, which is a vocabulary describing the sender rather than the reader. // The audit log is where the four are distinguished, because there the // reader is an operator asking a different question. // // m53.md asks for the notification on the path that matters most — *a // recovery code being spent is the signal that either the phone is gone or // somebody else has it* — and the title is what carries which of the four // happened. KindMFAChanged = "mfa.changed" // KindUpdateAvailable tells the instance principal that a newer LinkCtrl has // been published (M55). // // **The instance principal, not every account.** Whether the box is up to // date is an operator's question: upgrading it means pulling an image and // restarting a service, which a workspace member cannot do and would only be // made anxious by. Same recipient and same reasoning as KindAuditGrowth, and // the same correction F49 recorded about telling people who cannot act. // // **No mail.** The audit-growth warning earned mail because the thing it warns // about gets worse while it is ignored — the table keeps growing. A newer // release does not: it is exactly as available next month, and the inbox is // where somebody who signs in will see it. KindUpdateAvailable = "update.available" )
Kinds are the notification vocabulary. Stored verbatim and read by operators, and extended by later milestones without coordinating with this file.
const AuditGrowthReminderInterval = 7 * 24 * time.Hour
AuditGrowthReminderInterval is how long one audit-growth warning suppresses the next.
The threshold stays crossed until an operator acts on it, so without this the hourly job would file a notification every hour forever — and an inbox filling up with the same line is one people stop opening, which would cost exactly the warning D5's keep-forever default leans on. A week is long enough to be ignorable while somebody plans the work, short enough not to fall out of mind.
const KindAutomationFired = "automation.fired"
KindAutomationFired is one rule firing. The rule is named in the data, so an inbox filtered to this kind reads as a list of what the scheduler did.
const MailAuditGrowth = "audit-growth"
MailAuditGrowth names the mail template for the same warning. It is the filename in internal/ui/templates/mail, without the extension, and it is also what lands in the outbox's `kind` column.
const MailDisputeDecided = "dispute-decided"
MailDisputeDecided names the template for a dispute outcome (D1's addendum to M32). Same convention as above: the filename, without the extension.
The template name is here rather than in internal/dispute for one reason — this package owns the mailer, and a consumer that names a template it cannot render is a send that fails at the relay instead of at boot. internal/ui parses every template in that directory at startup, so a name that has no file takes the process down before anybody disputes anything.
const PreviewLimit = 5
PreviewLimit is how many unread notifications the header's bell shows before deferring to the full page.
Small on purpose. The bell answers "is there anything, and roughly what" — a question a person asks in passing, from whatever page they were reading — and /notifications answers "show me everything", with pagination and mark-read. A preview long enough to scroll would be a worse version of the page it links to, so it is cut well before that and says how many are left.
const RoleOwner = "owner"
RoleOwner is the role notified about things that concern the organization rather than a person.
Variables ¶
This section is empty.
Functions ¶
func HumanBytes ¶
HumanBytes renders a size the way an operator reads one. Binary units, because that is what df and every disk-sizing conversation uses.
Types ¶
type Enqueuer ¶
type Enqueuer interface {
Enqueue(ctx context.Context, to, kind string, data map[string]string) error
}
Enqueuer is internal/mail's writing half, as this package needs it.
Declared here rather than imported so that notify keeps depending on nothing but the store: the consumer owns the interface, and a test satisfies it with a slice.
type Event ¶
type Event struct {
Kind string
Title string
Body string
Data map[string]any
WorkspaceID *uuid.UUID
}
Event is a notification about to be written. The recipient is a separate argument, so a caller cannot accidentally address one Event at two people while sharing its mutable Data map.
type Notification ¶
type Notification struct {
ID uuid.UUID `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
// Data is per-kind detail. Shape is the kind's business, not this
// package's, and it is returned verbatim.
Data map[string]any `json:"data,omitempty"`
// WorkspaceID is the workspace this notification belongs to, when it belongs
// to one. Absent on anything that is the organization's — a dispute
// decision, an audit-growth warning — which is what makes those visible
// wherever the reader is standing.
//
// Returned since 0.2.0. The column was written from M40 onward and read by
// nothing (F105), while two comments stated it produced a per-workspace
// inbox; D102 built the filter those comments described.
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
Notification is one item in a user's inbox.
type Notifier ¶
Notifier is the writing half, as its consumers see it. An interface so a consumer takes a nil-able dependency and its tests need no database.
type Recipient ¶
type Recipient struct {
UserID uuid.UUID
Email string
// Name is what a mail greets them by. Empty is common — the column defaults
// to it — so callers use Greeting rather than this.
Name string
}
Recipient is one person to tell, in both forms: the id an inbox row is keyed by, and the address a mail goes to.
One type rather than two lookups. Every consumer that emails also files the in-app notification — in-app is the baseline and mail is the addition — so fetching the address separately would be a query per recipient for something the first query already had in hand.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service writes and reads notifications.
func NewService ¶
func (*Service) AnnounceRelease ¶ added in v0.3.0
AnnounceRelease tells the instance principal that a newer LinkCtrl exists (M55), at most once for any one version.
Here rather than in internal/update for the reason WarnAuditGrowth is here rather than in the job runner: who hears it, and how often, is policy worth testing, and internal/update should be able to be a client and a comparison without also knowing what an instance principal is.
Once per version, not once per week ¶
WarnAuditGrowth suppresses on a clock because the condition it reports stays true and gets worse. This one is keyed on the version itself: an operator told about 0.4.0 is not told again about 0.4.0, ever, and 0.5.0 is a new fact that arrives once. A weekly re-raise would be the product nagging about a decision the operator has already made, which is how an inbox stops being read.
The guard is per recipient, so appointing a second principal after a release lands tells the new one and does not re-tell the first.
No mail, and an empty recipient set is not a failure ¶
See KindUpdateAvailable for the first. For the second: an instance whose principal grants were all revoked hears nothing, exactly as it hears no audit-growth warning, and fanning out to every tenant instead would be the defect F49 recorded.
`running` is what this binary reports and is used for the sentence alone — internal/update has already decided that `available` is newer, and deciding it twice in two places is how the two answers eventually differ. It is a parameter rather than a read of internal/build so that this policy is testable without a linker flag.
func (*Service) AutomationFired ¶
func (s *Service) AutomationFired( ctx context.Context, orgID uuid.UUID, workspaceID uuid.UUID, ruleID uuid.UUID, ruleName, trigger string, matched int, subjects []string, ) error
AutomationFired tells a workspace's owners that one of its rules ran.
Addressed to the owners this workspace has, which is who OwnersOf answers with: the organization-wide ones, plus anybody holding owner scoped to this workspace. A rule is a workspace object and its firing names the links it matched, so an owner scoped to a *different* workspace is not a recipient — they hold no membership through which those links are theirs to read. The notification carries the owning workspace so it appears in that workspace's inbox rather than wherever the reader happens to be standing.
True since 0.2.0 and not before (F105, D102): the column was written from M40 onward and read by nothing — no query selected it, the domain type had no field for it — so this sentence described an inbox scope that did not exist, in the one place a reader would look before adding the filter themselves.
`subjects` is the human list, already bounded by the caller. `matched` is the real count, which can be larger when a run was truncated at its per-rule cap — and printing the count separately from the list is what stops a truncated firing reading like a complete one.
func (*Service) EveryReviewer ¶
EveryReviewer lists the users to tell about something concerning the *instance* rather than one organization.
It replaces EveryOwner, which walked every organization on the box and told each one's owners. That was the only recipient set available before D98 introduced an instance-level principal — the blocklist and the disputes about it cross every organization (M31), so "everybody who might be able to act" was approximated by "every owner of everything". The approximation was the amplifier in [F137]: one filer could put an unbounded number of disputes in front of a recipient list that grows with every registration on an instance running LINKCTRL_SIGNUP_MODE=open, and neither rate-limiting the filer nor capping the queue touches a multiplier that is the recipient list.
[F137]: ../../docs/build-notes/deferred-findings.md
Since D98 the people who can act are a named set, so this asks who they are rather than guessing. It reads the review half rather than the decide half: a reviewer holds both in the ordinary case, and the one who has been left with only reading is still somebody who should hear that the queue moved.
No deduplication, unlike the loop it replaces: a grant is one row per (user, permission), so the query cannot return an account twice.
It is never empty on a claimed instance. The setup flow confers the principal in the same transaction that creates the first account, and migration 03400 confers it on the earliest surviving account of an instance that already existed — so an instance with disputes to file is an instance with somebody to tell. An empty result means the operator revoked every grant, and the honest answer to that is no notification rather than a broadcast.
func (*Service) Get ¶ added in v0.3.0
func (s *Service) Get(ctx context.Context, actor *auth.Identity, id uuid.UUID) (Notification, error)
Get returns one of the actor's own notifications.
The click-through's first call (M48): where a notification leads is a function of its kind and its data, and both are read off the row here rather than carried on the request that asked to open it.
A notification belonging to somebody else answers ErrNotFound, the same answer an id that never existed gets, so the pair cannot be told apart. That is the rule MarkRead below already follows, spelled as an error because this one has a value to return.
func (*Service) List ¶
func (s *Service) List(ctx context.Context, actor *auth.Identity, f Filter) (*domain.Page[Notification], error)
List returns a page of the actor's own notifications, newest first.
The actor's own, always. There is no permission for reading somebody else's inbox because there is no reason to have one, and the query is scoped by user_id rather than filtered afterwards.
func (*Service) Mail ¶
func (s *Service) Mail(ctx context.Context, to Recipient, template string, data map[string]string) error
Mail queues the email form of a notification, if there is a mailer.
The optionality lives here and nowhere else, which is the whole point of routing consumers through this package: a caller writes the inbox row and then calls this, and on an instance with no SMTP_HOST the second call returns immediately and the outbox stays empty. No consumer branches on whether mail is configured, so none of them can get the branch wrong.
AppURL is added to the data here rather than by each caller, for the reason mailAuditGrowth reads it from the service: there is no request in scope on the paths that send, and an operator with two instances needs to know which one is writing to them.
func (*Service) MarkAllRead ¶
MarkAllRead empties the badge, reporting how many it cleared.
func (*Service) MarkRead ¶
MarkRead marks one notification read, reporting whether it changed anything.
A notification that is not the actor's own is indistinguishable from one that does not exist: both are "nothing changed", so an id cannot be probed.
func (*Service) MarkUnread ¶ added in v0.3.0
MarkUnread puts one notification back in the unread list.
The owner's note is the whole justification: *"No way to mark a read message as unread if it was accidentally marked as read"*. M48 is also what makes the accident common — opening a notification now marks it read on the way past — so the undo ships with the thing that needs undoing rather than after it.
No schema change. `read_at` has been nullable since 00600 and NULL has always been what unread means, so this is an UPDATE and not a migration.
Same probe-resistance as MarkRead: somebody else's id changes no rows and reports success, because a 404 here would confirm the id exists.
func (*Service) NotifiedSince ¶
func (s *Service) NotifiedSince(ctx context.Context, userID uuid.UUID, kind string, since time.Time) (bool, error)
NotifiedSince reports whether this user already has a notification of this kind newer than `since`.
The re-notify guard, and it lives here rather than in the consumer because every recurring consumer needs the same thing: a condition that is still true on the next run is still true, and re-raising it hourly is how an inbox stops being read.
func (*Service) Notify ¶
Notify writes one notification to one user's inbox.
No permission check: a notification is a consequence of something that already happened, and the recipient is chosen by the consumer rather than requested by a caller. Reading is where authorization lives, and there it is simply "your own inbox".
func (*Service) NotifyMFAChange ¶ added in v0.3.0
NotifyMFAChange satisfies auth.MFANotifier (M53).
The seam's implementation, on this side for the reason internal/audit's RecordMFAChange is on that one: this package imports internal/auth, so internal/auth cannot import it.
**In-app only, and no mail.** That is the baseline this package is built on — WithMail is an addition every consumer may decline — and here it is also the right answer on its own terms. Three of the four events are things the person just did, on a page they are looking at; the fourth reaches them at the moment they are signing in, which is when they open the dashboard anyway. A mail would make a second factor's ordinary use noisy, and noisy security mail is mail people filter.
The body names the number of recovery codes left, because that is the one fact that turns "your second factor changed" into something to act on.
func (*Service) OwnersOf ¶
func (s *Service) OwnersOf( ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID, ) ([]Recipient, error)
OwnersOf lists the users to tell about something concerning the organization, or concerning one workspace in it.
The workspace is a parameter and not an afterthought: "owner" is a role held per membership, and a membership scoped to one workspace owns that workspace and not the organization (D44). So an organization-wide owner hears about everything, and a workspace-scoped owner hears about their own workspace and nothing else. Passing nil is news that belongs to no workspace — the audit log growing, the instance-wide blocklist — and reaches the organization-wide owners alone.
Callers that hold a workspace pass it. That is the whole correction: the query used to ignore the distinction, and a workspace-scoped owner was told about every hostname and every automation firing in workspaces they hold no membership in.
func (*Service) RecipientByID ¶
RecipientByID resolves one user into the pair a mail needs: their address and what to greet them by.
A deleted account resolves to the zero Recipient with a nil error rather than to ErrNotFound, because every caller is a notification about something that already happened and none of them should fail because the person it concerns has since left. A zero Recipient has no address, and Mail below does nothing with one.
func (*Service) Unread ¶
Unread is the count behind the badge, on its own.
The API's counterpart of UnreadPreview: a client polling for a number does not want the rows, and the endpoint that answers it renders nothing. The dashboard shell uses UnreadPreview instead, because it needs both and one query answers both.
func (*Service) UnreadPreview ¶
func (s *Service) UnreadPreview(ctx context.Context, actor *auth.Identity, limit int32) (int64, []Notification, error)
UnreadPreview is the header's entire notification lookup: the exact unread count for the badge, and the newest unread notifications for the bell.
One call, one query, because this runs on every dashboard page render. The count and the preview come back together rather than from a count followed by a list — see the query's comment for how the total stays exact while the rows stay bounded. Splitting them would double the per-render cost of a decoration.
A limit of zero or less means PreviewLimit; anything larger is clamped to it, so a caller cannot turn the header into an unbounded list.
func (*Service) WarnAuditGrowth ¶
WarnAuditGrowth tells the instance principal that the audit log has passed its size threshold, at most once per reminder interval.
Lives here rather than in the job runner because it is policy, not scheduling: what counts as "too big", who hears about it, and how often are decisions worth testing, and a job runner in package main cannot be reached by a test.
A threshold of zero or less disables the warning entirely — for an operator who has already decided and does not want reminding. That is the only way to switch it off, and it is deliberately not the default: keep-forever is safe only if the instance nobody configured is the one that gets warned (D19).
Who hears it, and why it stopped being everybody ¶
`audit_logs` is one table for the whole instance — the size has no organization predicate and could not have one — and the only thing that bounds it is `LINKCTRL_AUDIT_RETENTION_DAYS`, an environment variable with no dashboard control, no API and no non-config consumer. So the person who can act on this warning is whoever administers the deployment.
This used to mail every organization's owners. The justification was the rule this package applies everywhere else — tell the people who can act — and it was true when written, because an instance had one organization and its owner was the operator. [M28](../../docs/build-notes/phase-details/m28.md) made owner and operator different people, [M29](../../docs/build-notes/phase-details/m29.md) made owner mean anybody who registered, and the recipient list was never revisited: under `SIGNUP_MODE=open` the warning went to every account on the instance, weekly, carrying an operational number none of them could act on. The codebase argued against itself about it — D19 and this package's own tests say telling somebody who cannot act is noise in their inbox (F49).
The instance principal (D98) is the recipient the rule always implied and which did not exist to name until M45. An instance whose principal grant has been revoked hears nothing, and that is correct rather than a gap: the warning has no one it could usefully reach, and fanning back out to every tenant would be the defect again.
func (*Service) WarnDomainFailing ¶
func (s *Service) WarnDomainFailing( ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID, hostname, reason string, stopsAt time.Time, ) error
WarnDomainFailing tells a workspace's owners that its hostname is failing, and when serving stops if nothing changes.
Addressed to the owners this hostname's workspace has, which is who OwnersOf answers with — a wider set than the audit-growth warning reaches, because that one belongs to no workspace. The notification carries the owning workspace so it appears in that workspace's inbox rather than wherever the reader happens to be standing.
True since 0.2.0 and not before (F105, D102): the column was written from M40 onward and read by nothing — no query selected it, the domain type had no field for it — so this sentence described an inbox scope that did not exist, in the one place a reader would look before adding the filter themselves.
The deadline is in the body as a time and in the jsonb as a timestamp, because the sentence has to be readable now and the value has to be renderable later without parsing English back out of it.
func (*Service) WarnDomainUnverified ¶
func (s *Service) WarnDomainUnverified( ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID, hostname, reason string, ) error
WarnDomainUnverified tells a workspace's owners that the hostname has stopped being served.
func (*Service) WithMail ¶
WithMail attaches a mailer, so notifications that have an email form are also sent as one.
A setter rather than a constructor argument because the mailer is optional and every existing caller passes nothing. Handing a nil Enqueuer here is the same as never calling it: a nil interface, checked once at the send site.