Documentation

Notifications API

Reference for buildContentNotifications in the VitNode Content Engine, with the threadReply, moderationDecision and approvalPending presets, stable ids, custom rules, triggers and deduplication, access policies, delivery and retries, cleanup and useRevealHashTarget.

This page lists everything buildContentNotifications accepts and how its notifications behave. For a guided setup, follow Notifications. Examples use newsroom.comment and newsroom.article from the newsroom plugin.

buildContentNotifications()

plugins/newsroom/src/api/lib/notifications.ts
import { buildContentNotifications } from "@vitnode/core/content/server";

export const commentNotifications = buildContentNotifications({
  content: commentContentType,
  root: articleContentType,
  rules: {
    replies: { id: "newsroom.comment_reply", preset: "threadReply" /* ... */ },
  },
});

Server-only, from @vitnode/core/content/server. Register the result in buildApiPlugin({ contentNotifications: [commentNotifications] }). Each rule becomes one notification type, registered with the plugin.

NameTypeRequiredDefaultDescription
contentcontent type definitionYesThe content type whose operations notify. Its model must be registered with buildContentAdminModule
rootcontent type definitionFor threadReplyThe type thread.root points at. Checked at startup
access({ c, contentId, rootId, userIds }) => number[]NoNarrows who may see every preset notification, for content your plugin restricts to some members. Return the allowed ids, one query per batch
rulesobject of rulesYesAt least one. Keys are names in your code only

It throws ContentEngineError when an author field is not a to-one, shared field.user(), when threadReply is used without thread or without root, when two rules share an id, or when rules is empty. An invalid id or category throws NotificationRegistryError. At startup, an unregistered content type or a root that is not the thread.root target stops the app with a message naming the plugin.

Options shared by the presets

NameTypeRequiredDefaultDescription
idstringYesThe notification type id, such as "newsroom.comment_reply": at least two dot-separated segments of a-z, 0-9, _, -, at most 100 characters
categorystringNo"content"Groups types on the preferences page. approvalPending defaults to "moderation"
defaults.inAppbooleanNotrueWhether members get it in the bell before choosing
defaults.email"none" | "immediate" | "daily" | "weekly"No"none"Email before choosing. Anything but "none" enables email for the type
emailbooleanNofalseAllows email so members can opt in while the default stays "none"

Stable ids

id is stored with every notification event and every member's preference, so it must never change. The key in rules is not stored anywhere, so rename it freely. Two rules, in any plugin, cannot share an id.

threadReply preset

New comments and replies in a thread. Runs on the record's first publication.

NameTypeRequiredDefaultDescription
recipients.authorFieldauthor field of contentYesWho wrote the record: excluded from its own notification, and the recipient when someone answers it
recipients.rootAuthorFieldauthor field of rootNoThe root's author, told about every new record in the thread. Omit to skip
recipients.parentAuthorbooleanNotrueTell the author of the record being answered
messages.labelAPI message keyYesThe type's name in preferences
messages.descriptionAPI message keyNoOne line under the label
messages.commentAPI message keyYesTitle for the root's author. Receives {actor} and {title}
messages.replyAPI message keyYesTitle for the parent's author. Receives {actor} and {title}
target({ content, parent, root }) => stringYesSame-site path the notification opens. Called when the notification is sent; the result is stored
title({ content, root }) => stringNoroot's admin.titleFieldThe {title} value, cut at 300 characters
grouping.windowMinutesnumberNooffMerges a member's notifications for the same root and wording into one item within the window (1 to 10080). The item opens the newest one

Author fields are typed: only to-one, shared field.user() names of each content type compile. content, parent and root in target are typed rows of their content types.

A member who is both the root's author and the parent's author gets one notification with the reply text. The record's author and whoever made it public, such as the moderator who approved it, never receive it. {actor} is the record's author, or "Someone" when the account is gone.

Without rootAuthorField the rule notifies replies only, so the root type needs no author. {title} reads the root's own row, so a localized title field gives an empty string. Leave {title} out of the texts or pass title.

moderationDecision preset

Tells the submitter of a moderated create or edit what a moderator decided.

NameTypeRequiredDefaultDescription
decisions("approved" | "rejected")[]NobothWhich decisions notify
messages.labelAPI message keyYesThe type's name in preferences
messages.descriptionAPI message keyNoOne line under the label
messages.approvedAPI message keyYesReceives {title} and {kind} (create or edit)
messages.rejectedAPI message keyYesReceives {title}, {kind} and {reason} (empty without one)
target({ content, decision, root }) => string | nullYesWhere it leads. A rejected create is a draft, so link somewhere its author can open
title({ content, root }) => stringNoroot's, then the record's admin.titleFieldThe {title} value

The recipient is the submission's authorId, which is the person who proposed the change, not necessarily the record's author. The moderator who decided is not named on the notification and is never notified about their own submission. Approving a create also runs the record's first-publication rules; approving an edit does not.

approvalPending preset

Tells moderators that a new record or a proposed edit waits in the approval queue, and links to that submission at /modcp/approvals/{id}. The Forum plugin registers one for topics and one for comments:

plugins/forum/src/api/lib/notifications.ts
pending: {
  id: "forum.topic_pending",
  preset: "approvalPending",
  messages: {
    label: "@vitnode/forum.notifications.topic_pending.label",
    description: "@vitnode/forum.notifications.topic_pending.description",
    pending: "@vitnode/forum.notifications.topic_pending.pending",
  },
},
NameTypeRequiredDefaultDescription
messages.labelAPI message keyYesThe type's name in preferences
messages.descriptionAPI message keyNoOne line under the label
messages.pendingAPI message keyYesReceives {kind} (create or edit) and {container}, the name of the container the record is filed in

The link is fixed, so there is no target, and the text carries no title or body: a moderator who loses access keeps nothing readable about the content. A typical message is "{kind, select, edit {An edit to a topic is waiting for approval in {container}} other {A new topic is waiting for approval in {container}}}".

Recipients are the moderators who could decide the submission: their can_approve for the content type, merged from their own entry and their roles, must cover the container the record is filed in, with the same container scopes the queue uses. Root roles and unrestricted moderator entries qualify everywhere. Being staff, or an administrator, is not enough. Permissions are read from the database when the notification is sent, not from the staff permission cache, and the submitter is never told about their own submission. At most 1000 permission holders are considered per submission; past that, the rest are skipped with a warning in the logs.

The preset only applies to content types whose container collection has publishing policies and whose publicWrite is on, because the queue lists nothing else.

Custom rules

A rule with type instead of preset publishes an existing notification type on a supported trigger:

plugins/newsroom/src/api/lib/notifications.ts
import {
  contentNotificationAccess,
  contentNotificationRefs,
} from "@vitnode/core/content/server";
import { buildNotificationType } from "@vitnode/core/api/lib/notifications/registry";
import { z } from "zod";

const deskCommentNotification = buildNotificationType({
  id: "newsroom.desk_comment",
  version: 1,
  schema: z.object({
    contentId: z.string(),
    contentTypeId: z.string(),
    rootId: z.string().nullable(),
    target: z.string(),
  }),
  category: "content",
  label: "newsroom.notifications.desk_comment.label",
  defaults: { inApp: true, email: "none" },
  access: contentNotificationAccess({ content: commentContentType, policy: "content" }),
  present: ({ data, t }) => ({
    title: t("newsroom.notifications.desk_comment.title"),
    target: data.target,
  }),
});

// In buildContentNotifications({ ..., rules }):
deskEditors: {
  type: deskCommentNotification,
  on: "firstPublication",
  // Your own query: the user ids that should hear about it.
  recipients: async ({ c, root }) => await findDeskEditorIds(c, root),
  data: context => ({
    ...contentNotificationRefs(context),
    target: `/news/${String(context.root?.slug)}#comment-${String(context.content.id)}`,
  }),
},
NameTypeRequiredDefaultDescription
typebuildNotificationType() resultYesRegistered for you; listing it in notificationTypes as well is fine
on"firstPublication" | "moderationDecision"YesThe trigger
recipients(context) => (number | null | undefined)[]YesCandidate user ids. nulls and duplicates are dropped, and so is the actor unless allowSelf
data(context) => TDataYesData for the type's schema, typed from it
allowSelfbooleanNofalseLet the actor receive it

context holds content, parent and root (typed rows, reloaded when the notification is sent), decision (for moderationDecision: submissionId, status, kind, authorId, reviewerId, reason), actorId, trigger, contentTypeId and c. c is the background worker's context. The database works, but there is no signed-in user.

recipients and data can run more than once for one operation, because the worker retries after a failure. Keep them free of side effects: read and compute, never send email, call webhooks or write rows. Anything they throw retries the whole operation.

Give a custom type access: contentNotificationAccess({ content, policy }) and spread contentNotificationRefs(context) into its data, so core can check the record again later. Without it, only the type's own access (or none) applies.

Triggers and deduplication

Two different things decide whether something is sent twice:

  • Retrying one operation never duplicates anything. Each publication carries a key built from the operation (first-publication:{contentTypeId}:{id}:{relation} or decision:{contentTypeId}:{submissionId} or approval:{contentTypeId}:{submissionId}), and core stores at most one event per type and key, enforced by a unique index. A worker that publishes and then crashes before finishing finds the same event on its retry.
  • Separate operations notify again only when the trigger allows it:
TriggerRunsAgain later?
firstPublicationWhen the record becomes public for the first time: a published create, a publish, a scheduled publish when it fires, or the approval of a createNever. Unpublishing and republishing, hiding and showing, restoring a revision and editing do not run it
moderationDecisionWhen a moderator approves or rejects a submissionOnce per submission, so each review cycle can notify
approvalSubmitted (approvalPending only)When a submission starts waiting: a pending create, an edit proposal, or a newer proposal that replaces oneOnce per submission, so a resubmission after a rejection notifies again

VitNode recognises the first publication inside the publishing transaction, where the record's publishedAt is still NULL under the row lock. Once set, VitNode never rewrites it. That state lives on the record itself, not in anyone's inbox, so deleting a notification or the passing of time cannot make a comment notify again. Content published before the rules existed already has a publishedAt, so adding rules sends nothing for it, and no migration or backfill is needed.

Access

Core checks a notification type's access before delivering it, before every immediate or digest email and every time an inbox loads. Presets use contentNotificationAccess:

PolicyA member may see it while
"content" (threadReply)The record and its root exist and are public: published, not hidden, not scheduled for later. For a reply, the comment it answers still exists
"submission" (moderationDecision)The member is the submission's author, the submission still has that decision and the record exists. An approval also needs the record to be public; a rejection does not. The root must stay public
"approval" (approvalPending)The submission is still pending, the record exists, and the member holds can_approve both for the container the queue lists the submission under and for the one the record is filed in now. The record does not have to be public

A notification that fails the check shows "This notification is no longer available", with no title, text or link, and its email is skipped. Content Engine reads are not role-based, so add access to buildContentNotifications when your plugin limits content to some members. An email that was already sent cannot be taken back.

Delivery and retries

  1. The operation commits. In the same transaction VitNode stored an intent: a content-notifications task in core_queue holding only ids and the facts of the operation (who acted, which decision). A rolled-back operation leaves no intent. An existing transaction is joined, never committed early.
  2. Right after the response, or on the next run of the queue cron, a worker claims the task (FOR UPDATE SKIP LOCKED, so two workers never take the same one), reloads the records and checks they are still public.
  3. It resolves recipients, calls your callbacks and publishes every notification of the operation in one transaction.
  4. Core fans out to inboxes and emails, checking access again.

A failure retries with backoff up to 5 attempts, then the task is marked failed with its error, visible with the other queue tasks. A rule or content type that is no longer registered is skipped with a warning in the logs, never retried forever. Nothing depends on a long-running process. Any request or the cron endpoint drives the queue.

Cleanup

Deleting a record stores a cleanup intent in the same transaction. Once it runs, the record's notifications leave every inbox, and so do notifications about records in its thread when the record was the root, including replies a database cascade removed. A grouped item loses only the deleted contributions and points at the newest one left; it disappears when none is left. Pending emails for those notifications are dropped.

A submission that stops waiting, because a moderator decided it, the author replaced it with a newer proposal or the record was deleted, stores a cleanup intent in the same transaction, and its approvalPending notifications leave the moderators' inboxes. Delivery that has not reached an inbox yet is stopped by the access check; in the rare case that delivery and cleanup cross, the item stays behind as "no longer available".

Hiding is not deleting. A hidden record's notifications are only unavailable until it is shown again. A delete that races with a notification being sent is safe either way, because the access check runs again at every step.

useRevealHashTarget()

import { useRevealHashTarget } from "@vitnode/core/hooks/use-reveal-hash-target";

Brings the element named by the URL hash into view in a paginated list. Until the element exists it loads the next page; then it scrolls the element to the middle of the screen, keeps it there while late content settles and focuses it (give the element tabIndex={-1}).

NameTypeRequiredDefaultDescription
prefixstringYesOnly hashes starting with it react, such as "comment-"
readybooleanYesfalse while the first page is pending
renderedCountnumberYesItems on screen; a change looks again
hasMorebooleanYesWhether another page exists
isLoadingMorebooleanYesWhether a page is loading
loadMore() => Promise<unknown>YesLoads the next page
maxLoadsnumberNo10Pages to load while looking

It returns { status, targetId }, where status is "idle", "searching", "found" or "missing". Show a short notice for "missing" so a stale link is not silently ignored.