Skip to content

feat(article): add comments on articles - #211

Merged
aquie00t merged 1 commit into
mainfrom
feature/article-comments
Aug 25, 2026
Merged

feat(article): add comments on articles#211
aquie00t merged 1 commit into
mainfrom
feature/article-comments

Conversation

@aquie00t

Copy link
Copy Markdown
Collaborator

What does this PR do?

Stage 5 of the article feature, and the riskiest one — comments become polymorphic so an article thread reuses the post comment machinery instead of cloning it.

POST /api/v1/articles/:articleId/comments    auth,     STANDARD
GET  /api/v1/articles/:articleId/comments    optional, PUBLIC

Those are the only two new endpoints. Replies, comment detail, likes, bookmarks and deletion all keep working through the existing /comments/:commentId routes, because article comments live in the same table. That is the entire payoff: a separate ArticleComment model would have needed its own like and bookmark tables, two repositories, a mapper, seven duplicated use cases and six more routes — and every future comment fix applied twice.

The schema change

Comment.postId becomes nullable and articleId appears beside it. Two nullable columns would on their own permit a comment attached to nothing, or to both, so the invariant is held by a CHECK constraint:

ALTER TABLE "comments"
  ADD CONSTRAINT "comments_target_xor"
  CHECK (num_nonnulls("post_id", "article_id") = 1);

Prisma cannot express a CHECK, which also means it does not know this one exists — so tests/integration/persistence/comment-target-constraint.test.ts is the guard. It asserts both-null and both-set are rejected on insert and on update, so a future migrate dev that regenerates the table without the constraint fails loudly instead of the invariant quietly disappearing.

DROP NOT NULL and adding a nullable column are metadata-only in Postgres, so no table rewrite happens. Callers branch on comment.target rather than on which id is null, keeping the two-column representation inside the entity.

Two things that had to move in lockstep with the column

1. The response schema. CommentResponse.postId and CommentItemSchema.postId are now nullable, with articleId alongside. fast-json-stringify coerces a value that does not match its schema rather than rejecting it, so leaving the schema promising a string would have emitted a wrong postId for every article comment — silent data corruption, not an error.

2. delete-comment. It decremented Post.commentCount unconditionally. It now branches on the target: posts keep today's behaviour, articles have no counter to maintain.

Article comment count is derived

_count: { select: { comments: true } }, not a column. A counter would drift exactly the way posts.comment_count does, because the reply subtree is removed by a database cascade the application never observes. An e2e test pins this: a parent with two replies is deleted, and the count falls by three.

Writing the e2e suite found a leak in my first version

A stranger commenting on someone else's draft got 409 ArticleNotPublishedError — which confirms the draft exists. Visibility is now checked before publication status:

caller article state result
stranger draft / archived 404, identical to a nonexistent article
author own draft / archived 409, told it is not published yet

The same split is covered for the comment list.

Notifications

Article replies use COMMENT_REPLY, which already existed in the Prisma enum but not the TypeScript one. Post replies keep using COMMENT, so their behaviour is unchanged. referenceId is populated for the first time — it has been plumbed end to end since the beginning and never set.

Verification

740 unit tests pass (721 existing + 19 new), lint, format:check, build clean. Booting the app confirms DI still resolves (GetPostCommentsUseCase gained a repository) and that the shared /comments/:commentId routes are intact.

The regression gate: tests/e2e/comment/* is untouched by this PR and must stay green — that is what proves the post comment path still behaves exactly as before. The unit test for the post half of CreateCommentUseCase is likewise left in place; only its input shape changed.

New e2e coverage lives in tests/e2e/article/comments.test.ts: creation, nested replies through /comments/:id/replies, reading and liking and deleting through the shared routes, the draft splits above, cross-thread parent rejection, and the derived comment count.

Note

GetPostCommentsUseCase now serves both targets, so its name is misleading. Renaming it touches the DI key and the controller, so per the plan it happens in stage 6 alongside deleting the deprecated findTopLevelByPostId wrapper, rather than adding diff noise to the migration PR.


Type of Change

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Chore

Checklist

  • My branch follows the naming convention (feature/, fix/, chore/, docs/)
  • My commits follow Conventional Commits
  • I have tested my changes locally
  • I have not introduced any breaking changes
  • I have updated relevant documentation if needed

🤖 Generated with Claude Code

Stage 5 of the article feature, and the riskiest: comments become polymorphic
so an article thread reuses the post comment machinery instead of cloning it.

  POST /api/v1/articles/:articleId/comments
  GET  /api/v1/articles/:articleId/comments

Those are the only two new endpoints. Replies, comment detail, likes,
bookmarks and deletion keep working through the existing /comments/:commentId
routes, because article comments live in the same table. A separate
ArticleComment model would have needed its own like and bookmark tables, two
repositories, a mapper and seven duplicated use cases, and every future
comment fix applied twice.

Comment.postId becomes nullable and articleId appears beside it. Two nullable
columns would on their own permit a comment attached to nothing, or to both,
so a CHECK constraint holds the invariant. Prisma cannot express one, so it is
written by hand and covered by integration tests that fail if a future
migrate dev regenerates the table without it. DROP NOT NULL and adding a
nullable column are metadata-only in Postgres, so the table is not rewritten.

Callers branch on comment.target rather than on which id is null, keeping the
two-column representation inside the entity.

Two things that had to move in lockstep with the column:

- CommentResponse.postId and CommentItemSchema.postId are now nullable.
  fast-json-stringify coerces a value that does not match its schema instead
  of rejecting it, so leaving the schema promising a string would have emitted
  a wrong postId for every article comment rather than failing loudly.
- delete-comment decremented Post.commentCount unconditionally. It now
  branches on the target; posts keep today's behaviour, articles have no
  counter to maintain.

Articles derive commentCount from a relation count. A counter column would
drift the way posts.comment_count does, since the reply subtree is removed by
a database cascade the application never sees - an e2e test pins that by
deleting a parent with two replies and asserting the count falls by three.

Writing the e2e suite surfaced a leak in the first version of this change: a
stranger commenting on someone else's draft got 409, which confirms the draft
exists. Visibility is now checked first, so a stranger gets 404 and only the
author is told their own article is not published yet.

Notifications: article replies use COMMENT_REPLY, which existed in the Prisma
enum but not in the TS one. Post replies keep using COMMENT so their behaviour
is unchanged. referenceId is populated for the first time.

The existing tests/e2e/comment suite is unchanged and passing, which is the
regression gate for the post path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aquie00t
aquie00t merged commit da44a3b into main Aug 25, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/article-comments branch August 25, 2026 00:05
github-actions Bot pushed a commit that referenced this pull request Aug 25, 2026
# [1.5.0](v1.4.0...v1.5.0) (2026-08-25)

### Features

* **article:** add comments on articles ([#211](#211)) ([da44a3b](da44a3b))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.5.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant