notify

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 6, 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"
)

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