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 writecodegen-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 writecodegen-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 writecodegen-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.

Generatedfe/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 editfe/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 writeCategoryDetailPage.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.