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) 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) 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) 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) 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" )
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) 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) 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) 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) 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.