feat(article): add the article read path - #208
Conversation
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>
|
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 The Two migrations are merged into
Neither runs automatically. The Dockerfile has no migration step, and The part that needs a decision: So the ordering matters:
Happy to walk through step 2 once the branch exists. |
# [1.3.0](v1.2.0...v1.3.0) (2026-08-24) ### Features * **article:** add the article read path ([#208](#208)) ([56fc950](56fc950))
|
🎉 This PR is included in version 1.3.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
What does this PR do?
Stage 3 of the article feature — the read path.
/api/v1/articles/api/v1/articles/me/api/v1/articles/:slugDraft containment now has both layers
Stage 1 shipped the first: every list query in the repository pins
statustoPUBLISHED. This stage adds the second —findBySlugdeliberately 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.
GetMyArticlesUseCasehas 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 thearticles:list:*pattern the write path already invalidates on publish, archive, and edits or deletes of a published article.Two deliberate choices:
get-posts.usecase.tsrehydrates 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=BACKEND,FRONTENDand?categories=FRONTEND,BACKENDshare 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.
GetArticlesUseCasetook a constructor parameter namedfollowRepository, while the container registersfollowUserRepository:Under awilix CLASSIC, arguments resolve by parameter name.
tscwas 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 useasFunction— the wrapper maps cradle keys to positional arguments, which hides the mismatch.2. Route precedence.
/articles/memust win over/articles/:slug, sincemematches 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:checkandbuildclean.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
articlestable does not exist in that database. Both merged migrations (add_articlesandrestore_missing_foreign_keys) are inmainbut 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
Checklist
feature/,fix/,chore/,docs/)🤖 Generated with Claude Code