Skip to content

feat: add content approvals, container policies, and forum plugin - #876

Merged
aXenDeveloper merged 9 commits into
canaryfrom
claude/community-forum
Oct 14, 2026
Merged

aXenDeveloper merged 9 commits into
canaryfrom
claude/community-forum

Conversation

@aXenDeveloper

Copy link
Copy Markdown
Owner

Description

What?

This PR introduces three major features to the VitNode Content Engine:

  1. Content Approvals System: A complete workflow for moderating user-submitted content (new posts and edits) before publication. Includes:

    • Submission queue and approval/rejection logic
    • Moderation UI for reviewing pending content
    • Staff permission controls for approval workflows
    • Database schema for tracking submissions and decisions
  2. Container Publishing Policies: Per-container rules that control how content is published:

    • Configurable policies for auto-approval, requiring moderator review, or blocking submissions
    • Policy overrides for specific user groups
    • Admin UI for managing policies per container
    • Validation and enforcement in the public write API
  3. Forum Plugin (@vitnode/forum): A complete community forum implementation featuring:

    • Forum containers (hierarchical board structure)
    • Topics (threaded discussions) and replies (nested responses)
    • Topic creation, editing, and reply composition
    • Pending content view for users awaiting approval
    • Forum statistics and navigation
    • Localization support (English included)

Why?

These features enable VitNode to support moderated community discussions. The approval system gives site administrators control over content quality, while container policies provide flexible moderation rules. The forum plugin demonstrates the full capability of the content engine with a production-ready community forum.

Key Changes

  • Database: New core_content_submissions table for tracking approval submissions
  • Content Engine:
    • New defineThread() API for threaded content types
    • Public people resolution for content attribution
    • Write authorization checks against container policies
    • Approval queue management and decision handling
  • Moderation: New approval review routes and UI components
  • Admin: Container policy configuration interface
  • Tests: Comprehensive test coverage for policies, approvals, and forum functionality

All changes maintain backward compatibility with existing content types and APIs.

https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN

…ontent

- Per-container publishing policies with inheritance (Stage 3)
- Container-scoped moderator can_edit / can_approve via a moderation scope family
- Approval submissions for new records and edits of published ones (Stage 4)
- Moderator approval queue API in the moderation module
- Threads: to-one self relations, placement guard and GET /thread (Stage 5)
- Generated slugs, publicApi.people and container-through-relation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN
…hing policies

- /modcp/approvals list and detail with compare, approve and reject
- Publishing sheet per container with inherit choices and sources
- approval state on public-write records; cancel submissions on delete
- Export policy, approval and thread APIs from the content entry points

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN
- forum.forums container with publishing policies, forum.topic and threaded forum.reply
- Forum index, forum, topic thread, new/edit topic, reply permalink and my-pending pages
- Public stats routes, JSON-LD and canonical URLs, page cache on public pages
- Migration for container policies, content submissions and forum tables
- Fit the AdminCP nav namespaces into the request budget as plugins grow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN
- Policy resolution, author edit window, scope resources and expansion
- thread, container relation, generated slug and publicApi.people definitions
- Public writes under policies, approval decisions and queue scoping
- Thread route, container policy admin routes, nav namespace budget
- Fill a thread page past hidden top-level replies nobody answered

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN
@github-actions github-actions Bot added the 💡 Feature A new feature label Oct 13, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T11:07:56.630952Z c32bf45 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

React Doctor found 23 new issues in 17 files · 23 warnings · score 72 / 100 (Needs work) · 3 fixed · vs canary

23 warnings

src/api/models/search.ts

  • ⚠️ L716 await inside a loop async-await-in-loop

src/content/approvals/server/queue.ts

  • ⚠️ L173 Array lookup inside a loop js-set-map-lookups
  • ⚠️ L414 Sequential independent awaits server-sequential-independent-await

src/content/define-public-api.ts

  • ⚠️ L150 Array lookup inside a loop js-set-map-lookups

src/content/server/public-write-service.ts

  • ⚠️ L520 Sequential independent awaits server-sequential-independent-await
  • ⚠️ L552 Sequential independent awaits server-sequential-independent-await

src/content/server/search-sync.ts

  • ⚠️ L237 await inside a loop async-await-in-loop
  • ⚠️ L280 await inside a loop async-await-in-loop
  • ⚠️ L323 await inside a loop async-await-in-loop

src/content/server/write-effects.ts

  • ⚠️ L99 await inside a loop async-await-in-loop

src/pages/forum-errors.ts

  • ⚠️ L62 Array lookup inside a loop js-set-map-lookups

src/pages/forum-page.tsx

  • ⚠️ L122 Heading has no content heading-has-content
  • ⚠️ L299 Heading has no content heading-has-content

src/pages/forum-ui.tsx

  • ⚠️ L67 Heading has no content heading-has-content

src/pages/forums-page.tsx

  • ⚠️ L71 Heading has no content heading-has-content

src/pages/pending-page.tsx

  • ⚠️ L175 Heading has no content heading-has-content

src/pages/thread.tsx

  • ⚠️ L384 Heading has no content heading-has-content

src/pages/topic-new-page.tsx

  • ⚠️ L114 Heading has no content heading-has-content

src/views/admin/views/containers/container-manager.tsx

  • ⚠️ L211 React function has high control-flow complexity no-high-complexity-react-function

src/views/moderation/modcp/approvals/approval-fields.tsx

  • ⚠️ L221 Array lookup inside a loop js-set-map-lookups

src/views/search/search-feed-content.tsx

  • ⚠️ L396 Intl formatter rebuilt each call js-hoist-intl

src/views/search/search-filters.tsx

  • ⚠️ L121 Array lookup inside a loop js-set-map-lookups
  • ⚠️ L171 Prop derived into useState no-derived-useState

Reviewed by React Doctor for commit c32bf45. See inline comments for fixes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b70234d5a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +723 to 724
const access = await authorize(tx, actor, await lockRow(tx, id));
await assertPublicRelationTargets(tx, values);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce the policy of the destination container

When a public-writable container field or container-bearing relation is included in update.fields, authorization and needsReview are calculated from the locked row's current container, but the subsequent update may move the record elsewhere. An owner can therefore create content in an immediate/open container and PATCH it into a moderators-only or approval-required container without satisfying that destination's policy; a scoped moderator can likewise move content outside their granted scope. Resolve the post-update container from the merged values and authorize/review against that destination before applying or approving the move.

Useful? React with 👍 / 👎.

Comment on lines +447 to +448
if (pending) {
await refreshContentSubmissionValues(tx, pending.id, await valuesOf(row));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read approval snapshots through the active transaction

For a draft with collection-valued public fields, this runs inside the update transaction but valuesOf calls advancedFields without { tx }, so it reads through the context database before the new junction/file rows commit. The pending submission consequently retains the previous collection values while the draft contains the new ones, allowing a moderator to approve content different from the snapshot they reviewed. Pass the active transaction through valuesOf for submission creation and refreshes.

Useful? React with 👍 / 👎.

Comment on lines +67 to +72
items: z
.array(zodSubmission)
.parse(data.items)
.filter(item => item.pluginId === CONFIG_PLUGIN.pluginId),
page: data.page,
pageCount: Math.max(1, Math.ceil(data.total / data.pageSize)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Paginate the forum subset before rendering it

When a member has submissions from multiple plugins, this fetches a globally paginated page and only then removes non-forum rows, while pageCount still uses the global total. A page can therefore display the forum's empty state even though forum submissions exist on later pages, and pagination contains empty or incorrectly counted pages. The API query needs a plugin/content-type filter, or the loader must otherwise paginate the filtered forum result.

Useful? React with 👍 / 👎.

const collected: NodeRow[] = [];
let position = decodeCursor(after);

for (let round = 0; round < 5 && collected.length <= first; round++) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue scanning past hidden top-level replies

If more than five batches of hidden, unanswered top-level replies precede the next public reply, this fixed round limit exits with too few or zero collected roots. hasNextPage is then false and the cursor is based only on the collected page, so every public reply after that hidden run becomes unreachable; with the default page size, 105 such hidden rows are enough. Continue until a page is filled or the raw query is exhausted, while retaining a separate safety bound that can return a continuation cursor.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: efa7566adc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +249 to +251
const { row } = await reviewableSubmission(c, c.req.valid("param").id);

return c.json(await showContentSubmission(c, row), 200, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck current scope before showing approval details

When a record has moved since its submission, reviewableSubmission authorizes this GET using only the submission's stored containerId, after which showContentSubmission returns the live row and up to 20 history entries. A moderator scoped to the old container can therefore read current values and submissions from containers outside their grant, even though the decision path explicitly rechecks the live container and refuses the same moderator. Resolve and authorize the live record container before returning the detail, and scope or omit history entries the moderator cannot review.

Useful? React with 👍 / 👎.

Comment on lines +124 to +132
if (submission.kind === "create") {
if (row.status === "published") return { kind: "none" };

if (editorial) {
const service = model.editorialService?.(c, { pluginId });

return {
kind: "editorial",
outcome: (await service?.publish(id, { actor, tx })) ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate relation targets when approving submissions

When a relation target is hidden, unpublished, or otherwise becomes unavailable after submission, both approval branches publish/apply the stored values without repeating the public-write relation check. This allows an approved create or edit to introduce a public relation that an ordinary public write would reject; for example, a queued comment can be published after its topic becomes non-public. Re-run relation-target validation inside the decision transaction before publishing or updating.

Useful? React with 👍 / 👎.

Comment on lines +278 to +285
if (sync === "delete") {
if (typeof itemId === "number") {
await c.get("search").deleteByContainer({
containerId: itemId,
containerType: definition.id,
itemType: dependent.id,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove dependent search documents for string context IDs

For a relation-based search context whose target uses the UUID or bigint ID strategy, itemId is a string, so deleting the target skips dependent cleanup entirely. Those dependent documents can remain public in the canonical index and external provider—with stale context metadata or even links to cascade-deleted records—until a full reindex. Either support string-valued context keys in the search index and deletion API or reject non-serial relation contexts during definition validation.

Useful? React with 👍 / 👎.


const contentTypes = this.c.get("core")?.contentTypes ?? [];
for (const { itemId } of rows) {
await this.delete(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs


return new Map(
rows
.filter(row => wanted.includes(row.id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

)
.limit(20);

const [self] = await toListItems(c, [row]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/server-sequential-independent-await (warning)

This awaited initializer does not read the previous result. If the operations are independent asynchronous work, they may overlap; await syntax alone does not establish a speedup.

Fix → Consider Promise.all([...]) only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics.

Docs

);
}

if (exposed.includes(name) || (localized && name === "locale")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

tx,
});
const { container, decision } = await prepare(tx);
const created = await asInputError(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/server-sequential-independent-await (warning)

This awaited initializer does not read the previous result. If the operations are independent asynchronous work, they may overlap; await syntax alone does not establish a speedup.

Fix → Consider Promise.all([...]) only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics.

Docs

<Empty className="border">
<EmptyHeader>
<EmptyMedia variant="icon">{icon}</EmptyMedia>
<EmptyTitle render={<h1 />}>{title}</EmptyTitle>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/heading-has-content (warning)

Blind users can't use this heading to navigate because screen readers skip it empty, so add text, aria-label, or aria-labelledby.

Fix → Put readable text in every heading.

Docs

<EmptyMedia variant="icon">
<MessagesSquareIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle render={<h2 />}>{t("empty.title")}</EmptyTitle>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/heading-has-content (warning)

Blind users can't use this heading to navigate because screen readers skip it empty, so add text, aria-label, or aria-labelledby.

Fix → Put readable text in every heading.

Docs

<EmptyMedia variant="icon">
<InboxIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle render={<h2 />}>{t("empty.title")}</EmptyTitle>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/heading-has-content (warning)

Blind users can't use this heading to navigate because screen readers skip it empty, so add text, aria-label, or aria-labelledby.

Fix → Put readable text in every heading.

Docs

<EmptyMedia variant="icon">
<MessageSquareIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle render={<h3 />}>{t("empty.title")}</EmptyTitle>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/heading-has-content (warning)

Blind users can't use this heading to navigate because screen readers skip it empty, so add text, aria-label, or aria-labelledby.

Fix → Put readable text in every heading.

Docs

<EmptyMedia variant="icon">
<FolderTreeIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle render={<h2 />}>{t("new.no_forums.title")}</EmptyTitle>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/heading-has-content (warning)

Blind users can't use this heading to navigate because screen readers skip it empty, so add text, aria-label, or aria-labelledby.

Fix → Put readable text in every heading.

Docs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c32bf45871

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 153 to +154
authorId: positiveIntOrUndefined(query.authorId),
authorIds: positiveIntListOrUndefined(query.authorIds),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply multi-author filters in Elasticsearch

When an installation uses the supported Elasticsearch adapter, selecting authors in the new search UI sends only authorIds, but packages/elasticsearch/src/index.ts builds filters solely from the legacy singular params.authorId. The request therefore returns unfiltered results while presenting the selected authors as active filters. Add equivalent authorIds handling to the Elasticsearch provider before exposing this parameter universally.

Useful? React with 👍 / 👎.

Comment on lines +45 to +47
throw redirect({
href: `${topicHref(target.slug)}#${commentAnchorId(record.slug)}`,
statusCode: 302,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load the page containing a permalinked comment

When the target comment belongs to a top-level reply after the first 20 roots, this redirect lands on a topic page whose initial thread payload does not contain the generated anchor. Nothing uses the comment ID or a cursor to fetch the containing page, so the permalink neither displays nor scrolls to the requested comment; manually loading more also does not reliably re-run fragment navigation. Resolve the target root/page and include enough navigation state for the topic page to load it.

Useful? React with 👍 / 👎.

Comment on lines +139 to +144
depth += 1;
if (depth > maxDepth) {
refuse(
parentField,
`Replies nest at most ${maxDepth} levels deep. Answer a reply higher up.`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include descendant height when validating thread moves

When an existing reply with children is reparented within the same root through the content service, this check validates only the moved reply's new ancestor depth. A valid depth-1 subtree of height 5 can therefore be moved under a depth-2 reply and leave descendants deeper than maxDepth; readContentThread stops traversing at that limit, so those records remain counted and stored but disappear from the rendered thread. Validate the deepest descendant as part of a parent change, or reject moving non-leaf replies.

Useful? React with 👍 / 👎.

@aXenDeveloper
aXenDeveloper added this pull request to stack #878 October 14, 2026 18:51
@aXenDeveloper
aXenDeveloper merged commit 695535a into canary Oct 14, 2026
7 checks passed
@aXenDeveloper
aXenDeveloper deleted the claude/community-forum branch October 14, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants