Commenting¶
Layer: Backend · a resource-level opt-in flag
Threaded, rich-text comments on any resource row. One shared polymorphic
comments table serves every opted-in resource; a resource joins with a
one-line opt-in and codegen mounts GET/POST/DELETE sub-routes on its router.
Note
Commenting is not yet merged to main — it lives on the commenting-audit
branch. Import paths, helper signatures, and the config schema may change before
release.
How it works¶
You supply the shared Comment model (subclass fsh_lib.comments.CommentMixin)
and migrate its table. A resource opts in with comments: comments.target() —
presence alone mounts the sub-routes via the router’s extra_mounts slot,
dispatching to fsh_lib.comments + codegen_database.ext.comments against your
model.
The config you write¶
You write — codegen-example-app/be/fsh/models/comment.py: one shared table,
columns inherited from the mixin, PK/timestamp injected by plugins:
from codegen_database.plugins.pk import UUIDV4PKPlugin
from fsh_lib.comments import CommentMixin
from db.base import Base
class Comment(Base, CommentMixin):
"""Threaded rich-text comment -- columns from CommentMixin."""
# One polymorphic table backs every opted-in resource. CommentMixin
# supplies resource_type (lowercase model slug, discriminates rows so
# comments never bleed across resources), resource_id (stringified PK,
# scopes per row), parent_comment_id (reply target, text — no DB FK,
# integrity enforced at the route layer), body (Slate Descendant[] as
# JSONB), author_id. Index (resource_type, resource_id) and
# parent_comment_id — both drive every list query. You migrate this table.
__tablename__ = "comments"
__table_args__ = {"schema": "public"}
__plugins__ = [UUIDV4PKPlugin()] # id PK; TimestampPlugin adds created_at
You write — codegen-example-app/be/config/project.jsonnet: names the shared
model. This block gates the opt-in — a resource declaring comments without it
is rejected at config-load (_comments_target_requires_project_comments):
local comments = import "be/comments/comments.libsonnet";
{
// ...
comments: comments.platform({
model: "fsh.models.Comment", // dotted path to your CommentMixin subclass
}),
}
You write — codegen-example-app/be/config/inventory_resources/category.jsonnet:
one line among the resource’s normal blocks. target() takes no args — presence
is the switch:
local comments = import "be/comments/comments.libsonnet";
{
model: "inventory.models.Category",
route_prefix: "/categories",
cache_key: "categories",
// ...
comments: comments.target(), // mounts GET/POST/DELETE /{prefix}/{id}/comments
audit: true,
}
The frontend codegen emits¶
Generated — from codegen-be/src/be/templates/fastapi/comments_routes.py.j2
(do not edit): three handlers mounted at the resource’s route_prefix. Auth is
inferred from the project auth block — no auth block, no session
requirement:
@router.get("/{id}/comments", response_model=list[CommentResponse], tags=["comments"])
async def list_comments_category(id: uuid.UUID, ...) -> list[CommentResponse]:
# Flat parent-first stream (ORDER BY parent_comment_id NULLS FIRST,
# created_at); build_comment_thread nests it into a tree.
q = construct_comments_thread_query(
Comment.__table__, resource_type="category", resource_id=str(id),
)
rows = (await db.execute(q)).all()
return build_comment_thread(list(rows))
# POST: top-level or reply. A parent_comment_id must name a comment on the same
# (resource_type, resource_id) — validated here, not by a DB FK; 201.
# DELETE: 204; author-gated in auth projects (403 otherwise); replies orphaned
# (promoted to top-level), never cascaded.
# Both stamp author_id from session.<user_id_attr> ("anonymous" with no auth)
# and emit X-Invalidate-Queries via QueryInvalidations to refresh the FE cache.
For category (mounted under app prefix /inventory) that yields
GET/POST/DELETE /inventory/categories/{id}/comments.
Generated — fe/src/_generated/queries/inventory/category_comments.gen.ts
(do not edit): typed query/mutation hooks plus a toCommentsTree nester over the
generated SDK:
export function useCategoryComments(resourceId: string) {
return useQuery({
queryKey: ["categories", resourceId, "comments"],
queryFn: ({ signal }) => listCategoryComments({ path: { id: resourceId }, signal }),
});
}
// + useCreateCategoryComment / useDeleteCategoryComment mutations, and
// toCommentsTree(rows): CommentResponse[] -> Comment[] for <FSHComments>.
Scaffolded, safe to edit — fe/src/api/inventory/category_comments.tsx: the
props binding codegen writes once, then you own:
import type { FSHCommentsProps } from "@fsh/components-library/editor";
export function useCategoryCommentsProps(resourceId: string): FSHCommentsProps {
const { data: comments } = useCategoryComments(resourceId);
const create = useCreateCategoryComment(resourceId);
const del = useDeleteCategoryComment(resourceId);
return {
comments: toCommentsTree(comments ?? []),
mentions: [], // wire to your user list to enable @mentions
resolveAuthor: undefined, // wire to resolve author id -> {name}; else raw ids show
onCreate: (body, parentCommentId) => create.mutate({ body, parentId: parentCommentId }),
onDelete: (commentId) => del.mutate(commentId),
creating: create.isPending || del.isPending,
};
}
You write — CategoryDetailPage.tsx: drop the bespoke component into a page.
<FSHComments> (from @fsh/components-library/editor) hosts the Slate composer,
threaded render, and @mention typeahead:
import { FSHComments } from "@fsh/components-library/editor";
import { useCategoryCommentsProps } from "../../api/inventory/category_comments";
function CategoryComments({ categoryId }: { categoryId: string }) {
return <FSHComments {...useCategoryCommentsProps(categoryId)} />;
}
Wire mentions + resolveAuthor to a useListUsers-style hook to surface
avatars/names and enable @mentions. Lists render through
table configuration.
See also: resources, auth, permissions, table configuration.