notify

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 13 Imported by: 0

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

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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func HumanBytes(n int64) string

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 Filter

type Filter struct {
	Cursor     string
	Limit      int32
	UnreadOnly bool
}

Filter is a page request.

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

type Notifier interface {
	Notify(ctx context.Context, userID uuid.UUID, e Event) error
}

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.

func (Recipient) Greeting

func (r Recipient) Greeting() string

Greeting is the name to address this person by, falling back to the address.

"Hello owner@example.com" is a worse sentence than "Hello Ada" and a better one than "Hello ,".

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service writes and reads notifications.

func NewService

func NewService(pool *pgxpool.Pool) *Service

func (*Service) AnnounceRelease added in v0.3.0

func (s *Service) AnnounceRelease(ctx context.Context, running, available string) error

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

func (s *Service) EveryReviewer(ctx context.Context) ([]Recipient, error)

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

func (s *Service) MarkAllRead(ctx context.Context, actor *auth.Identity) (int64, error)

MarkAllRead empties the badge, reporting how many it cleared.

func (*Service) MarkRead

func (s *Service) MarkRead(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

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

func (s *Service) MarkUnread(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

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

func (s *Service) Notify(ctx context.Context, userID uuid.UUID, e Event) error

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

func (s *Service) NotifyMFAChange(ctx context.Context, ev auth.MFAChange) error

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

func (s *Service) RecipientByID(ctx context.Context, userID uuid.UUID) (Recipient, error)

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

func (s *Service) Unread(ctx context.Context, actor *auth.Identity) (int64, error)

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

func (s *Service) WarnAuditGrowth(ctx context.Context, size, threshold int64) error

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

func (s *Service) WithMail(m Enqueuer, appURL string) *Service

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.

Jump to

Keyboard shortcuts

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