Skip to content
Merged
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/admincp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,6 @@ A few things that keep it smooth:

## Check the result

Save the definition, restart `pnpm dev` and open **AdminCP → Example → Articles**. The API validates sorting and form input against the same definition, so the restart makes sure both sides read the new `admin` options.
Save the definition and open **AdminCP → Example → Articles**. `pnpm dev` reloads both the screens and the API, which validates sorting and form input against the same definition.

Next, give the screens the permissions they need in [Production checklist](/docs/content-engine/production), or read every option in the [reference](/docs/content-engine/reference).
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 53 additions & 1 deletion apps/web/content/docs/content-engine/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ icon: ClipboardCheck

import { TypeTable } from "fumadocs-ui/components/type-table";

import { ImgDocs } from "@/components/fumadocs/img";
import pending from "./approvals-pending-notice.webp";

When a container's [publishing policy](/docs/content-engine/container-policies) says `publish: "approve"` or `edits: "approve"`, a member's post does not go live straight away. The server stores a **submission**, a moderator decides, and only an approval changes what readers see. This page explains what waits where, what the author sees, and what a decision does. The moderator's side is in [Review approvals](/docs/dev/moderation/review-approvals).

## What waits, and where
Expand Down Expand Up @@ -64,7 +67,7 @@ For a proposal, `values` is still the live record and `approval.values` holds wh

`GET /{id}/edit` returns the record's latest submission when it is pending, or rejected and not replaced yet, so the author's edit page can say "waiting for a moderator" or show the reason. The shape is `zodContentApprovalState` from `@vitnode/core/content`.

To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member. Pass `pluginId` to keep only your plugin's submissions - the filter runs before the page is cut, so every page is full and `total` counts only yours:
To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member. Pass `pluginId` to keep only your plugin's submissions. The filter runs before the page is cut, so every page is full and `total` counts only yours:

```ts
import { fetcher } from "@vitnode/core/tanstack/fetcher";
Expand All @@ -85,6 +88,44 @@ export const fetchMySubmissions = async (page: number) => {

Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. The Forum plugin's `pending-page.tsx` renders this list at `/forum/me/pending`.

## Show it on your pages

Readers never see a pending record, so the author needs to hear what happened. Three places cover it, all from the `approval` object:

1. **After posting.** When `record.approval?.status === "pending"`, send the author to the record's edit page instead of its public page, which answers `404` until approval. See [Public create form](/docs/content-engine/public-write-frontend#when-a-post-waits-for-a-moderator).
2. **On the edit page.** `GET /{id}/edit` returns the pending or rejected submission. Show a notice above the form. The Forum plugin's `topic-edit-page.tsx` does it like this:

```tsx title="plugins/forum/src/pages/topic-edit-page.tsx"
const ApprovalNotice = ({ approval, status }: { approval: ContentApprovalState | null; status: string }) => {
const t = useTranslations("@vitnode/forum.topics.approval");

if (approval?.status === "rejected") {
return (
<Alert variant="destructive">
<AlertTitle>{t("rejected.title")}</AlertTitle>
<AlertDescription>
{approval.reason ? t("rejected.reason", { reason: approval.reason }) : t("rejected.desc")}
</AlertDescription>
</Alert>
);
}

if (approval?.status === "pending" && approval.kind === "edit") {
return <Alert variant="info"><AlertTitle>{t("pending_edit.title")}</AlertTitle></Alert>;
}

if (status === "draft" && approval?.status === "pending") {
return <Alert variant="info"><AlertTitle>{t("pending_create.title")}</AlertTitle></Alert>;
}

return null;
};
```

3. **In a "my submissions" list.** `GET /moderation/approvals/mine`, shown above, lists everything the author sent. The forum renders it at `/forum/me/pending`.

For a pending edit, open the form on `approval.values`, the proposal, instead of the live text, so the author keeps editing what they sent.

## What a decision does

A moderator approves or rejects from the ModCP or through the [approval routes](/docs/dev/moderation/reference#approval-routes).
Expand All @@ -110,3 +151,14 @@ 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).

A listener on `content.approval.rejected` is a good place to tell the author why their post did not make it, gently.

## Check the result

In the AdminCP, set a forum's policy to **Approve** for new topics, as in [Publishing policies](/docs/content-engine/container-policies). Sign in as a member and start a topic there. You land on the topic's edit page with the waiting notice, and the topic stays out of the forum until a moderator approves it in the [ModCP](/docs/dev/moderation/review-approvals).

<ImgDocs
withoutBackground
className="*:max-w-full"
src={pending}
alt="The forum's Edit topic page with an info notice saying the topic is waiting for a moderator, above the title and message fields"
/>
4 changes: 3 additions & 1 deletion apps/web/content/docs/content-engine/caching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ cross-origin-opener-policy: same-origin

So a published article shows up on the next request, with nothing to expire.

With [Redis](/docs/dev/cache/api) (`REDIS_URL`), the server also keeps the public list and detail responses for up to 60 seconds. Each entry is keyed by the parsed query, the resolved locale and a generation number per content type, and every content write moves that generation - so a write still shows up on the next request, and nothing old is ever read again. A record whose future `publishedAt` simply comes due, with no write, can take up to that minute to appear. Only responses that look the same to every reader are kept: a [hidden](/docs/content-engine/content-visibility) record shown to a moderator, a preview link and the delivery routes always go to Postgres. The language list and the container tree behind `includeDescendants` are cached the same way. There is nothing to configure.
With [Redis](/docs/dev/cache/api) (`REDIS_URL`), the server also keeps the public list and detail responses for up to 60 seconds. Each entry is keyed by the parsed query, the resolved locale and a generation number per content type. Every content write bumps that generation, so a write still shows up on the next request and a stale entry is never read. A record whose future `publishedAt` comes due without a write can take up to that minute to appear. The language list and the container tree behind `includeDescendants` are cached the same way. There is nothing to configure.

Only responses that look the same to every reader are cached. A [hidden](/docs/content-engine/content-visibility) record shown to its author, editor or moderator (on its page or in a list), a preview link and the delivery routes always go to Postgres.

Add caching where it pays off:

Expand Down
44 changes: 16 additions & 28 deletions apps/web/content/docs/content-engine/container-frontend.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -426,24 +426,25 @@ Render it with `AutoFormSelect` before the title and body fields:
On submit, send the board as a number inside `values`, like every other writable field:

```tsx title="plugins/example/src/pages/discussion-new-page.tsx"
const write = useContentPublicWrite({
fields: ["title", "body", "board"],
uniqueField: "title",
});

const onSubmit: AutoFormOnSubmit<DiscussionCreateFormSchema> = async (
{ board, ...values },
form,
) => {
const res = await fetcher({
plugin: manifest.id,
method: "post",
module: "content/discussions",
path: "/",
args: { body: { values: { ...values, board: Number(board) } } },
});

if (res.status === 201) {
// Redirect to the new discussion, as in Public write frontend.
return;
}

await rejectDiscussion(res, form);
const record = await write.submit(form, async () =>
await fetcher({
plugin: manifest.id,
method: "post",
module: "content/discussions",
path: "/",
args: { body: { values: { ...values, board: Number(board) } } },
}),
);
// Redirect to the new discussion, as in Public create form.
};
```

Expand All @@ -463,20 +464,7 @@ The server never trusts the id. It reads the board back inside the write's trans
}
```

`useDiscussionRejection` from [Public write frontend](/docs/content-engine/public-write-frontend#build-the-shared-form) already puts field issues under their field. Add `"board"` to its list of fields and a board-specific message for `unavailable`:

```tsx title="plugins/example/src/pages/discussion-form.tsx"
const DISCUSSION_FIELDS = ["title", "body", "board"] as const; // [!code highlight]

const messageOf = (code: string, field?: DiscussionField) =>
t(
field === "board" && code === "unavailable"
? "issues.board_unavailable"
: isKnownIssueCode(code)
? ISSUE_MESSAGE_KEYS[code]
: "invalid",
);
```
Because `"board"` is in `fields`, [`useContentPublicWrite`](/docs/content-engine/public-write-frontend) shows that issue under the board select.

The edit form stays as it was. A container field cannot be in `publicWrite.update.fields`, so moving a discussion to another board is a staff edit in the AdminCP.

Expand Down
22 changes: 2 additions & 20 deletions apps/web/content/docs/content-engine/container-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ implementation:
href: /docs/dev/moderation/scoped-permissions
---

import { Tab, Tabs } from "fumadocs-ui/components/tabs";

Publishing policies let each container decide how members post in it: who may start, whether new records go live or wait for a moderator, whether edits to live records wait too, and how long authors may edit. One forum can be a free-for-all while the one next to it reviews every post.

This guide uses the Forum plugin: the `forum.forums` container collection, with topics (`forum.topic`) filed into forums and comments (`forum.comment`) living in their topic's forum.
Expand Down Expand Up @@ -74,25 +72,9 @@ Topics go live straight away and stay editable by their authors. Comments go liv

<Step>

### Build and restart

Policies are stored in a column core already has, so there is no migration. Rebuild the plugin and restart `pnpm dev`:

<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build plugins">

```bash tab="bun"
bun run build:plugins
```

```bash tab="pnpm"
pnpm build:plugins
```

```bash tab="npm"
npm run build:plugins
```
### Save

</Tabs>
Policies are stored in a column core already has, so there is no migration. `pnpm dev` picks up the change when you save.

Turning policies on also gives each content type in the collection container-scoped `can_edit` and `can_approve` moderator permissions. Grant and label them as described in [Container-scoped permissions](/docs/dev/moderation/scoped-permissions#container-scoped-permissions).

Expand Down
12 changes: 6 additions & 6 deletions apps/web/content/docs/content-engine/containers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,22 +241,22 @@ Without labels, the screen falls back to a name derived from the id, such as "Bo

<Step>

### Build, migrate and restart
### Migrate

Run this from the workspace root, then restart `pnpm dev`:
Run this from the workspace root while `pnpm dev` is running:

<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Build plugins and migrate">
<Tabs groupId="package-manager" persist items={["bun", "pnpm", "npm"]} label="Migrate the database">

```bash tab="bun"
bun run build:plugins && bun run db:migrate
bun run db:migrate
```

```bash tab="pnpm"
pnpm build:plugins && pnpm db:migrate
pnpm db:migrate
```

```bash tab="npm"
npm run build:plugins && npm run db:migrate
npm run db:migrate
```

</Tabs>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading