Skip to content

feat(article): add the article read path - #208

Merged
aquie00t merged 1 commit into
mainfrom
feature/article-read-path
Aug 24, 2026
Merged

feat(article): add the article read path#208
aquie00t merged 1 commit into
mainfrom
feature/article-read-path

Conversation

@aquie00t

Copy link
Copy Markdown
Collaborator

What does this PR do?

Stage 3 of the article feature — the read path.

Method Path Auth Rate limit Behaviour
GET /api/v1/articles optional PUBLIC published only, cached 60s
GET /api/v1/articles/me required STANDARD drafts included, never cached
GET /api/v1/articles/:slug optional PUBLIC 404 when not visible

Draft containment now has both layers

Stage 1 shipped the first: every list query in the repository pins status to PUBLISHED. This stage adds the second — findBySlug deliberately returns an article of any status so an author can read their own draft back, and the use case decides who may see it.

A viewer who may not see it gets 404, not 403. A distinct status code for a draft that exists would confirm the slug, which is exactly the leak these two layers exist to prevent. An unknown author username returns an empty page for the same reason, rather than a 404 that would distinguish "no such user" from "user with nothing published".

The e2e suite asserts this directly: an unknown slug and a real draft slug must return the same status and the same error title.

GetMyArticlesUseCase has no cache dependency at all. Sharing a cache with the public list is precisely how a draft leaks into it, so the ability is absent rather than merely unused.

Cache

Key: articles:list:page:P:limit:L:tag:T:author:A:categories:C:followedOnly:F:user:U, TTL 60s, cleared by the articles:list:* pattern the write path already invalidates on publish, archive, and edits or deletes of a published article.

Two deliberate choices:

  • The cached page is an explicit DTO, rebuilt field by field. get-posts.usecase.ts rehydrates with ...(data as any), which keeps stale shapes alive across deploys and lets the reader silently accept them. This one names every field and parses the dates back.
  • Categories are sorted into the key, so ?categories=BACKEND,FRONTEND and ?categories=FRONTEND,BACKEND share one entry instead of splitting the cache.

Every filter appears in the key with absent values as literals (ALL, guest), so the key space stays flat and one pattern delete clears it.

Two things this run caught that type checking could not

1. A DI break that compiled cleanly. GetArticlesUseCase took a constructor parameter named followRepository, while the container registers followUserRepository:

AwilixResolutionError: Could not resolve 'followRepository'.
Resolution path: articleController -> getArticlesUseCase -> followRepository

Under awilix CLASSIC, arguments resolve by parameter name. tsc was perfectly happy; the failure appeared only when the container built the controller at boot. Fixed, and the constructor now carries a comment saying the names are load-bearing. Worth noting this is also why several older registrations use asFunction — the wrapper maps cradle keys to positional arguments, which hides the mismatch.

2. Route precedence. /articles/me must win over /articles/:slug, since me matches the slug pattern. find-my-way scores the static segment higher, confirmed by the route answering 401 rather than the slug route's 404 — and there is an e2e test pinning that behaviour so a future refactor cannot silently swallow the endpoint.

Verification

708 unit tests pass (687 existing + 21 new), lint, format:check and build clean.

I also booted the real app to check DI resolution and routing, since neither shows up in a type check. Only read queries were issued — development and production still share a connection string.

That boot surfaced something worth flagging separately, unrelated to this PR's correctness: the articles table does not exist in that database. Both merged migrations (add_articles and restore_missing_foreign_keys) are in main but have never been applied there, so the article endpoints answer 500 against it. Details in the PR comment.

The e2e suite here — including the dedicated draft-visibility and cache-isolation blocks — gets its first real run in CI.


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 3 of the article feature: the public list, a single article by slug, and
an author's own articles.

  GET /articles          optional auth, PUBLIC    published only, cached 60s
  GET /articles/me       auth required, STANDARD  drafts included, never cached
  GET /articles/:slug    optional auth, PUBLIC    404 when not visible

Draft containment now has both of its layers. The repository pins status to
PUBLISHED on every list query, and this stage adds the second: the slug lookup
returns an article of any status so an author can read their own draft back,
and the use case decides who may see it. A viewer who may not see it gets a
404 rather than a 403, because a distinct status code for a draft that exists
would confirm the slug - which is the leak the two layers exist to prevent.
An unknown author username returns an empty page for the same reason.

GetMyArticlesUseCase has no cache dependency at all. Sharing a cache with the
public list is exactly how a draft leaks into it, so the ability is absent
rather than merely unused.

The cached page is written through an explicit DTO and rebuilt field by field,
rather than spreading whatever the entity serialized to. A loose shape keeps
stale fields alive across deploys and the reader silently accepts them. The
key carries every filter plus the viewer, with absent values as literals, so
one pattern delete clears the whole space.

Categories are sorted into the key so the same filter in a different order
reuses one entry instead of splitting the cache.

Two things this run caught that type checking could not:

- GetArticlesUseCase took a constructor parameter named followRepository while
  the container registers followUserRepository. Under awilix CLASSIC that
  resolves by name, so it compiled cleanly and failed only when the container
  built the controller. Booting the app is the only check that finds this.
- /articles/me is matched ahead of /articles/:slug by find-my-way, confirmed
  by the route answering 401 rather than the slug route's 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aquie00t

Copy link
Copy Markdown
Collaborator Author

Flagging an operational issue found while booting the app for this PR — it is not caused by this branch, but it affects what is on main right now.

The articles table does not exist in the database that .env.development and .env.production both point at.

PrismaClientKnownRequestError:
Invalid `this.prisma.article.findUnique()` invocation
The table `public.articles` does not exist in the current database.

Two migrations are merged into main and have never been applied there:

  • 20260824203007_add_articles — creates the article tables
  • 20260824215030_restore_missing_foreign_keys — restores the 21 missing foreign keys

Neither runs automatically. The Dockerfile has no migration step, and render.yaml deploys on commit, so the deployed image contains the article routes while the database has no article tables. Any call to /api/v1/articles* in production answers 500 until migrations are applied.

The part that needs a decision: prisma migrate deploy applies both pending migrations. Since development and production still share one database, running it there would also apply the foreign-key repair — the exact change we wanted to rehearse on a dev branch first. It would delete the 2 orphaned post_likes rows and add 21 constraints to the live database in the same command.

So the ordering matters:

  1. Create the Neon dev branch and point .env.development at it.
  2. Run pnpm db:deploy against the dev branch. Both migrations apply, and the foreign-key repair is rehearsed against a copy of real data.
  3. Only then decide on production, deliberately rather than as a side effect of wanting the article tables.

Happy to walk through step 2 once the branch exists.

@aquie00t
aquie00t merged commit 56fc950 into main Aug 24, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/article-read-path branch August 24, 2026 22:55
github-actions Bot pushed a commit that referenced this pull request Aug 24, 2026
# [1.3.0](v1.2.0...v1.3.0) (2026-08-24)

### Features

* **article:** add the article read path ([#208](#208)) ([56fc950](56fc950))
@github-actions

Copy link
Copy Markdown

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