Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Deleting a record cancels its pending submission, from the AdminCP or anywhere e

Opening a submission emits `content.approval.submitted`, and a decision emits `content.approval.approved` or `content.approval.rejected`. They carry the submission id, the content type, the record id, the container, the author and, for decisions, the reviewer. The record's own events are not duplicated: a pending create emits `created` but not `published` until it is approved. Payloads are in [Built-in events](/docs/dev/events/built-in-events#content-approval-events).

To tell authors about decisions in their notification inbox, use the [`moderationDecision` preset](/docs/content-engine/notifications#tell-authors-about-moderation-decisions) instead of a listener: it is stored with the decision and sent exactly once.
To tell authors about decisions in their notification inbox, use the [`moderationDecision` preset](/docs/content-engine/notifications#tell-authors-about-moderation-decisions) instead of a listener: it is stored with the decision and sent exactly once. To tell moderators that something waits, use the [`approvalPending` preset](/docs/content-engine/notifications-api#approvalpending-preset): it is stored with the submission and reaches only moderators whose `can_approve` covers the record's container.

## Check the result

Expand Down
38 changes: 35 additions & 3 deletions apps/web/content/docs/content-engine/notifications-api.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Notifications API
description: Reference for buildContentNotifications in the VitNode Content Engine, with the threadReply and moderationDecision presets, stable ids, custom rules, triggers and deduplication, access policies, delivery and retries, cleanup and useRevealHashTarget.
description: 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.
icon: BellRing
---

Expand Down Expand Up @@ -36,7 +36,7 @@ It throws `ContentEngineError` when an author field is not a to-one, shared `fie
| Name | Type | Required | Default | Description |
| ---------------- | -------------------------------------------------- | -------- | ----------- | ----------- |
| `id` | `string` | Yes | | The notification type id, such as `"newsroom.comment_reply"`: at least two dot-separated segments of `a-z`, `0-9`, `_`, `-`, at most 100 characters |
| `category` | `string` | No | `"content"` | Groups types on the preferences page |
| `category` | `string` | No | `"content"` | Groups types on the preferences page. `approvalPending` defaults to `"moderation"` |
| `defaults.inApp` | `boolean` | No | `true` | Whether 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 |
| `email` | `boolean` | No | `false` | Allows email so members can opt in while the default stays `"none"` |
Expand Down Expand Up @@ -84,6 +84,34 @@ Tells the submitter of a [moderated](/docs/content-engine/approvals) create or e

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](/docs/dev/moderation/review-approvals), and links to that submission at `/modcp/approvals/{id}`. The Forum plugin registers one for topics and one for comments:

```ts title="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",
},
},
```

| Name | Type | Required | Default | Description |
| ---------------------- | --------------- | -------- | ------- | ----------- |
| `messages.label` | API message key | Yes | | The type's name in preferences |
| `messages.description` | API message key | No | | One line under the label |
| `messages.pending` | API message key | Yes | | Receives `{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](/docs/dev/moderation/scoped-permissions#pick-forums-or-whole-subtrees) 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](/docs/content-engine/container-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:
Expand Down Expand Up @@ -148,13 +176,14 @@ Give a custom type `access: contentNotificationAccess({ content, policy })` and

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}`), 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.
- **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:

| Trigger | Runs | Again later? |
| --- | --- | --- |
| `firstPublication` | When the record becomes public for the first time: a published create, a publish, a scheduled publish when it fires, or the approval of a create | Never. Unpublishing and republishing, hiding and showing, restoring a revision and editing do not run it |
| `moderationDecision` | When a moderator approves or rejects a submission | Once 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 one | Once 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.

Expand All @@ -166,6 +195,7 @@ Core checks a notification type's `access` before delivering it, before every im
| --- | --- |
| `"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.

Expand All @@ -182,6 +212,8 @@ A failure retries with backoff up to 5 attempts, then the task is marked `failed

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()`
Expand Down
24 changes: 24 additions & 0 deletions apps/web/content/docs/content-engine/notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,30 @@ decisions: {

Each decision notifies the submitter once, so a resubmitted comment that is rejected again gets a second notification. A rejected comment is a draft that readers cannot open, so `target` sends its author to their pending list instead of an anchor the topic page does not have. The texts receive `{title}`, `{kind}` (`create` or `edit`) and `{reason}`.

## Tell moderators what waits for approval

The `approvalPending` preset notifies the moderators who could decide a pending comment or edit, and links to it in the Moderator CP. Add it next to the decisions rule:

```ts title="plugins/forum/src/api/lib/notifications.ts"
pending: {
id: "forum.comment_pending",
preset: "approvalPending",
messages: {
label: "@vitnode/forum.notifications.comment_pending.label",
pending: "@vitnode/forum.notifications.comment_pending.pending",
},
},
```

```json title="plugins/forum/src/locales/api/en.json"
"comment_pending": {
"label": "Comments awaiting approval",
"pending": "{kind, select, edit {An edit to a comment is waiting for approval in {container}} other {A new comment is waiting for approval in {container}}}"
}
```

Only moderators whose `can_approve` covers the comment's forum receive it, and it leaves their inboxes once someone decides. [Get notified](/docs/dev/moderation/review-approvals#get-notified) describes it from the moderator's side, and the [Notifications API](/docs/content-engine/notifications-api#approvalpending-preset) lists the options.

## Preferences and email

Each rule is an ordinary notification type. Members find **Comments and replies** under **Settings → Notifications** and can turn the notification list, push or email on and off for it. Administrators can change the defaults or switch the type off on the [AdminCP notifications page](/docs/dev/notifications/admincp). Email needs an email adapter and `email: true` (or a `defaults.email` other than `"none"`); immediate emails and daily or weekly digests then work like every other type.
Expand Down
25 changes: 25 additions & 0 deletions apps/web/content/docs/dev/moderation/review-approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,31 @@ Either way you land back in the queue with a short confirmation.

</Steps>

## Get notified

When a plugin registers an [`approvalPending` notification](/docs/content-engine/notifications-api#approvalpending-preset), as the Forum plugin does for topics and comments, you hear about new work in the notification bell. The item reads, for example, **A new topic is waiting for approval in General**, or **An edit to a topic is waiting for approval in General**, and opens the submission in the Moderator CP, where you approve or reject it as above. You do not need AdminCP access.

You get one notification per submission, and only when you could decide it:

- Your `can_approve` for that content type covers the container the content is filed in, through your own moderator entry or one of your roles, for that container, its subtree or **All resources**. A root role counts too. `can_approve` for topics says nothing about comments.
- You did not send it yourself.
- Overlapping grants still give you one notification.

What happens next:

| Situation | Notification |
| --- | --- |
| Someone approves or rejects it | Leaves your inbox |
| The author sends a newer version first | The old one leaves your inbox, and the newer version notifies again |
| The author resubmits after a rejection | A new notification, since it is a new submission |
| The content is deleted | Leaves your inbox |
| You lose `can_approve` there | Not sent, or shows "This notification is no longer available" with no text or link. The submission itself answers **Submission not found** |
| The content moves to another container before the notification goes out | Sent only to moderators who cover both the container it was submitted in and the new one |

The queue keeps listing a moved submission under the container it was submitted in until someone decides it, and the decision checks the new container. That is why a move narrows who is notified rather than notifying the new container's moderators; they find it once it is decided or resubmitted. Notifications that already arrived are not re-sent after a move.

Turn the type off or on under **Settings → Notifications**, in the **Moderation** group. It is in-app only: these notifications have no email.

## When a decision fails

| Message | What happened |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ A resource is either one container or one container with everything below it:
| `"12"` | Help | Container 12 only, never its children |
| `"12:tree"` | Help › * | Container 12 and every container below it, including ones created later |

The `:tree` entry is listed only for a container that has children. A record in container `C` is covered by a grant that names `C`, `C:tree` or the `:tree` of any ancestor, by **All resources**, by an unscoped grant, or by a root role. The server resolves the record's container on every check, and again when a decision is made, so a topic moved to another forum follows its new scope.
The `:tree` entry is listed only for a container that has children. A record in container `C` is covered by a grant that names `C`, `C:tree` or the `:tree` of any ancestor, by **All resources**, by an unscoped grant, or by a root role. The server resolves the record's container on every check, and again when a decision is made, so a topic moved to another forum follows its new scope. The same rule picks who is [notified about pending submissions](/docs/dev/moderation/review-approvals#get-notified).

A moderator entry on its own grants nothing. Someone flagged as staff, with no `can_edit` or `can_approve` for a forum, edits and approves exactly as much there as any member: nothing beyond their own posts.

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/locales/@vitnode/core/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -3249,6 +3249,7 @@
"categories": {
"account": "Konto",
"content": "Treści",
"moderation": "Moderacja",
"social": "Społeczność",
"system": "System"
},
Expand Down
Loading
Loading