Repository navigation
feat: add content approvals, container policies, and forum plugin - #876
Conversation
…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
…events 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
…emap 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
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
React Doctor found 23 new issues in 17 files · 23 warnings · score 72 / 100 (Needs work) · 3 fixed · vs 23 warnings
Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 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".
| const access = await authorize(tx, actor, await lockRow(tx, id)); | ||
| await assertPublicRelationTargets(tx, values); |
There was a problem hiding this comment.
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 👍 / 👎.
| if (pending) { | ||
| await refreshContentSubmissionValues(tx, pending.id, await valuesOf(row)); |
There was a problem hiding this comment.
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 👍 / 👎.
| 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)), |
There was a problem hiding this comment.
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++) { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
💡 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".
| const { row } = await reviewableSubmission(c, c.req.valid("param").id); | ||
|
|
||
| return c.json(await showContentSubmission(c, row), 200, { |
There was a problem hiding this comment.
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 👍 / 👎.
| 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, |
There was a problem hiding this comment.
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 👍 / 👎.
| if (sync === "delete") { | ||
| if (typeof itemId === "number") { | ||
| await c.get("search").deleteByContainer({ | ||
| containerId: itemId, | ||
| containerType: definition.id, | ||
| itemType: dependent.id, | ||
| }); | ||
| } |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
|
|
||
| return new Map( | ||
| rows | ||
| .filter(row => wanted.includes(row.id)) |
There was a problem hiding this comment.
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
| ) | ||
| .limit(20); | ||
|
|
||
| const [self] = await toListItems(c, [row]); |
There was a problem hiding this comment.
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.
| ); | ||
| } | ||
|
|
||
| if (exposed.includes(name) || (localized && name === "locale")) { |
There was a problem hiding this comment.
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
| tx, | ||
| }); | ||
| const { container, decision } = await prepare(tx); | ||
| const created = await asInputError( |
There was a problem hiding this comment.
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.
| <Empty className="border"> | ||
| <EmptyHeader> | ||
| <EmptyMedia variant="icon">{icon}</EmptyMedia> | ||
| <EmptyTitle render={<h1 />}>{title}</EmptyTitle> |
There was a problem hiding this comment.
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.
| <EmptyMedia variant="icon"> | ||
| <MessagesSquareIcon aria-hidden="true" /> | ||
| </EmptyMedia> | ||
| <EmptyTitle render={<h2 />}>{t("empty.title")}</EmptyTitle> |
There was a problem hiding this comment.
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.
| <EmptyMedia variant="icon"> | ||
| <InboxIcon aria-hidden="true" /> | ||
| </EmptyMedia> | ||
| <EmptyTitle render={<h2 />}>{t("empty.title")}</EmptyTitle> |
There was a problem hiding this comment.
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.
| <EmptyMedia variant="icon"> | ||
| <MessageSquareIcon aria-hidden="true" /> | ||
| </EmptyMedia> | ||
| <EmptyTitle render={<h3 />}>{t("empty.title")}</EmptyTitle> |
There was a problem hiding this comment.
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.
| <EmptyMedia variant="icon"> | ||
| <FolderTreeIcon aria-hidden="true" /> | ||
| </EmptyMedia> | ||
| <EmptyTitle render={<h2 />}>{t("new.no_forums.title")}</EmptyTitle> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| authorId: positiveIntOrUndefined(query.authorId), | ||
| authorIds: positiveIntListOrUndefined(query.authorIds), |
There was a problem hiding this comment.
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 👍 / 👎.
| throw redirect({ | ||
| href: `${topicHref(target.slug)}#${commentAnchorId(record.slug)}`, | ||
| statusCode: 302, |
There was a problem hiding this comment.
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 👍 / 👎.
| depth += 1; | ||
| if (depth > maxDepth) { | ||
| refuse( | ||
| parentField, | ||
| `Replies nest at most ${maxDepth} levels deep. Answer a reply higher up.`, | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
Description
What?
This PR introduces three major features to the VitNode Content Engine:
Content Approvals System: A complete workflow for moderating user-submitted content (new posts and edits) before publication. Includes:
Container Publishing Policies: Per-container rules that control how content is published:
Forum Plugin (
@vitnode/forum): A complete community forum implementation featuring: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
core_content_submissionstable for tracking approval submissionsdefineThread()API for threaded content typesAll changes maintain backward compatibility with existing content types and APIs.
https://claude.ai/code/session_01EGx8hKnebbZ9FxETGcK3PN