feat(article): add likes, bookmarks and tag integration - #212
Conversation
Final stage of the article feature. POST /api/v1/articles/:id/like DELETE /api/v1/articles/:id/like POST /api/v1/articles/:id/bookmark DELETE /api/v1/articles/:id/bookmark All four are idempotent, so a retried request cannot double-count or drive a counter negative. Liking an unpublished article answers 404 rather than a distinct status, keeping the draft invisible. Removing a bookmark deliberately does not load the article: a bookmark is the user's own row, so they must be able to drop one even after the article was archived. The tag repository is rewritten. findTrending previously loaded every tag matching the window together with every one of its post rows, then sorted in JavaScript; adding a second relation to that shape would have made it worse. Both counts now come from the database as filtered relation counts, and what is fetched per tag is a name and two integers. Only published articles count toward trends and search. A draft contributing to a public trend list would leak its existence, and would also let anyone push a tag into the trends by writing an article they never publish. Ordering still happens in memory because no single orderBy can express "posts plus articles"; the candidate set is bounded by the time window or the search term, so this is a different order of magnitude from before. TrendItem and TagSearchItem gain articleCount, and postCount keeps its post-only meaning so existing clients are unaffected. The trend and tag search response schemas gain the field in the same commit - fast-json-stringify drops properties a schema does not declare, so a response type without it would have silently omitted the count. Cleanups the plan deferred to this stage: - findTopLevelByPostId is gone from the port and the repository; every caller moved to findTopLevelByTarget in the previous stage. - GetPostCommentsUseCase now serves both posts and articles, so it is renamed to GetCommentsUseCase along with its folder, input and DI key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
E2E caught this: /tags/search answered 500 for any query that matched something, while an empty result stayed 200. SearchTagsUseCase remaps repository rows into its own DTO, and that DTO was not updated alongside the repository and the response schema, so articleCount was dropped between them. The schema declares it required, and fast-json-stringify fails serialization on a missing required property rather than omitting it - hence a 500 only when there was a row to serialize. GetTrendsUseCase passes TrendItem straight through, which is why trends was unaffected. The existing unit test did not catch it because its fixtures predate the field: toEqual ignores properties whose value is undefined, so tag.articleCount being undefined compared equal. The fixtures now carry the field and a test asserts the exact key set of each returned row, so a dropped field fails here instead of in CI. Also versions the trends cache key. Entries written before articleCount existed would otherwise be served for five minutes after a deploy and fail the same way, since the key was unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
CI caught a real bug in the first push — pushed a fix.
Cause: Why the unit test missed it: the fixtures predate the field, and Second issue found while fixing it: the trends result is cached in Redis for five minutes under an unchanged key. Entries written before 755 unit tests pass locally; waiting on CI. |
# [1.6.0](v1.5.0...v1.6.0) (2026-08-25) ### Features * **article:** add likes, bookmarks and tag integration ([#212](#212)) ([bcf06bd](bcf06bd))
|
🎉 This PR is included in version 1.6.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
What does this PR do?
Final stage of the article feature — likes, bookmarks, and tag integration.
All four are idempotent: a retried like cannot double-count, and unliking something never liked cannot drive the counter negative. Both are asserted in e2e. Liking or bookmarking an unpublished article answers 404 rather than a distinct status, so the draft stays invisible.
Removing a bookmark deliberately does not load the article. A bookmark is the user's own row, so they must be able to drop one even after the article was archived — loading the article first would trap it.
The tag repository rewrite
This is the substantial part.
findTrendingpreviously did this:Every tag in the window, together with every one of its post rows, pulled into Node and sorted there. Adding a second relation to that shape would have made it strictly worse.
Both counts now come from the database as filtered relation counts, and what is fetched per tag is a name and two integers:
Only published articles count. A draft contributing to a public trend list would leak its existence — and would also let anyone push a tag into the trends by writing an article they never publish. Both trends and search enforce it, and the integration suite asserts a draft's tag and an archived article's tag never appear.
Ordering still happens in memory, because no single
orderBycan express "posts plus articles". That is a deliberate trade: the candidate set is bounded by the time window or the search substring, and the payload per tag is now three small fields instead of an unbounded list of rows.TrendItemandTagSearchItemgainarticleCount;postCountkeeps its post-only meaning so existing clients are unaffected. The trend and tag-search response schemas gain the field in the same commit — fast-json-stringify drops properties a schema does not declare, so shipping the type change alone would have silently omitted the count.Cleanups the plan deferred to this stage
findTopLevelByPostIdis gone from the port and the repository. Every caller moved tofindTopLevelByTargetin stage 5.GetPostCommentsUseCase→GetCommentsUseCase. It has served both posts and articles since stage 5, so the name was misleading; the folder, input type and DI key move with it.Verification
754 unit tests pass (740 existing + 14 new),
lint,format:checkandbuildclean.Booting the app confirms every new use case, both repositories and the renamed
getCommentsUseCaseresolve through awilix, and that all four interaction routes register.Note
The rewritten tag queries could not be executed locally. The database
.env.developmentpoints at still has noarticlestable — the migrations merged in earlier stages have never been applied there — so both tag endpoints answer 500 against it. Prisma accepted the queries and reached SQL execution, which shows the filtered-count syntax is valid, but correctness is proved by CI. The new integration suite covers exactly that: article-only tags in trends, drafts and archived articles excluded, the two counts reported separately, and combined ranking. The existing post-only tag suite is untouched and acts as the regression gate.Type of Change
Checklist
feature/,fix/,chore/,docs/)🤖 Generated with Claude Code