Skip to content

feat(article): add likes, bookmarks and tag integration - #212

Merged
aquie00t merged 2 commits into
mainfrom
feature/article-interactions-and-tags
Aug 25, 2026
Merged

feat(article): add likes, bookmarks and tag integration#212
aquie00t merged 2 commits into
mainfrom
feature/article-interactions-and-tags

Conversation

@aquie00t

Copy link
Copy Markdown
Collaborator

What does this PR do?

Final stage of the article feature — likes, bookmarks, and tag integration.

POST   /api/v1/articles/:id/like        auth, STANDARD
DELETE /api/v1/articles/:id/like
POST   /api/v1/articles/:id/bookmark
DELETE /api/v1/articles/:id/bookmark

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. findTrending previously did this:

const rawTags = await this.prisma.tag.findMany({
    where: { posts: { some: { createdAt: { gte: windowStart } } } },
    include: { posts: { where: { createdAt: { gte: windowStart } }, select: { id: true } } },
});
// ...sorted in JavaScript

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:

select: {
    name: true,
    _count: {
        select: {
            posts: { where: postWindow },
            articles: { where: { status: PUBLISHED, publishedAt: { gte: windowStart } } },
        },
    },
},

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 orderBy can 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.

TrendItem and TagSearchItem gain articleCount; 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 shipping the type change alone 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 stage 5.
  • GetPostCommentsUseCaseGetCommentsUseCase. 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:check and build clean.

Booting the app confirms every new use case, both repositories and the renamed getCommentsUseCase resolve through awilix, and that all four interaction routes register.

Note

The rewritten tag queries could not be executed locally. The database .env.development points at still has no articles table — 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

  • 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

aquie00t and others added 2 commits August 25, 2026 03:24
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>
@aquie00t

Copy link
Copy Markdown
Collaborator Author

CI caught a real bug in the first push — pushed a fix.

GET /tags/search answered 500 for any query that matched something, while an empty result stayed 200. Three pre-existing tests in tests/e2e/trend/search-tags.test.ts failed alongside my new one, which is exactly what a regression gate is for.

Cause: SearchTagsUseCase remaps repository rows into its own SearchTagOutput DTO, and I updated the repository and the response schema but not the DTO in between. The schema declares articleCount required, and fast-json-stringify fails serialization on a missing required property rather than omitting it — so the 500 appeared only when there was a row to serialize. GetTrendsUseCase passes TrendItem straight through, which is why trends was unaffected and why the split looked so odd at first.

Why the unit test missed it: the fixtures predate the field, and toEqual ignores properties whose value is undefined — so tag.articleCount being undefined compared equal. The fixtures now carry the field, and a new test asserts the exact key set of each returned row, so a dropped field fails in unit tests instead of CI.

Second issue found while fixing it: the trends result is cached in Redis for five minutes under an unchanged key. Entries written before articleCount existed would have been served after deploy and failed serialization the same way. The key is now versioned (trends:v2:...), so stale entries cannot be reused.

755 unit tests pass locally; waiting on CI.

@aquie00t
aquie00t merged commit bcf06bd into main Aug 25, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/article-interactions-and-tags branch August 25, 2026 00:48
github-actions Bot pushed a commit that referenced this pull request Aug 25, 2026
# [1.6.0](v1.5.0...v1.6.0) (2026-08-25)

### Features

* **article:** add likes, bookmarks and tag integration ([#212](#212)) ([bcf06bd](bcf06bd))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.6.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