# 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: ```python 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`): ```jsonnet 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: ```jsonnet 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](auth.md) block — no auth block, no session requirement: ```python @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. ("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: ```typescript 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 . ``` **Scaffolded, safe to edit** — `fe/src/api/inventory/category_comments.tsx`: the props binding codegen writes once, then you own: ```typescript 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. `` (from `@fsh/components-library/editor`) hosts the Slate composer, threaded render, and @mention typeahead: ```typescript import { FSHComments } from "@fsh/components-library/editor"; import { useCategoryCommentsProps } from "../../api/inventory/category_comments"; function CategoryComments({ categoryId }: { categoryId: string }) { return ; } ``` Wire `mentions` + `resolveAuthor` to a `useListUsers`-style hook to surface avatars/names and enable @mentions. Lists render through [table configuration](table-configuration.md). See also: [resources](resources.md), [auth](auth.md), [permissions](permissions.md), [table configuration](table-configuration.md).