§ unswayed-backend
Blog (Phase 8)
Blog (Phase 8)
Phase 8 is the blog content system — the smallest phase so far, and deliberately a
leaf of the dependency graph: three nouns (blogs, likes, comments) over ten endpoints
in §7/§8, reusing only the Cloudinary storage port and the profile records. It lives in
src/blog/; ADR-0032 records the decisions. There are no emails, jobs, events, or
notifications anywhere in this domain — legacy parity.
Dual authorship — the load-bearing rule
A blog row has two mutually-exclusive provenances: app-created (user_id set,
created_by null) and admin-created (created_by set, user_id null). Phase 8
ships only the app/user side; the admin write side arrives with the Phase 12 admin
panel. Every ownership check here is simply blog.userId === auth id, which makes
admin blogs read-only to app users for free — their userId is null, so update and
delete always answer the exact legacy 403s (You can only update your own blog. /
You can only delete your own blog.), centralized in one tested assertBlogOwner
helper rather than legacy's two inline copies.
Both columns are nullable from the initial migration. That sounds trivial, but the
legacy app added user_id later via a raw per-driver ALTER with no SQLite branch and
a rollback that failed once user blogs existed — a documented footgun the rebuild
sidesteps entirely. created_by stays a plain column until Phase 12 lands the
admins table and its FK.
The reads, and the view counter that stopped hammering the database
GET /api/blogs and GET /api/blogs/{id} are public. Three frozen quirks shape
them:
- §7/§8 lists nest their pagination block under
meta— not the repo-widepaginationkey. The legacy controllers hand-built that block; the rebuild keeps the shape while reusing the shared paginator internally. - The list omits
likes_count/comments_count/is_likedentirely (legacy never loaded them;->when(isset)hid the keys). The detail includes the counts always andis_likedonly when the request is authenticated — the route uses the optional-auth guard, so a guest's response simply has nois_likedkey. full_articleis an alias ofdescription— both keys, same value.
The detail read increments views. Legacy did increment('views') unconditionally —
a database write on every public read, inflated by bots and refreshes. The rebuild's
ViewCounter keeps the contract but fixes the mechanism: a bounded in-memory map
dedups per viewer (blogId:{userId || ip}, 60-second TTL), and the actual UPDATE is
fire-and-forget (a DB failure is logged, never failing the read). The response
views stays deterministic: the stored value +1 when this read counted, the
stored value when deduped.
Writes and the thumbnail pipeline
Create/update/delete are owner-only and accept multipart or JSON. The thumbnail rules
are §7-exact: image MIME jpg/jpeg/png/webp, 10 MB cap — keyed 422s with the Laravel
message strings — and a 12 MB multer hard cap that turns anything bigger into a clean
413. Two legacy 500s are preserved verbatim: Failed to upload thumbnail. Please try again. (storage failure) and Failed to {create|update|delete} blog. Please try again. (anything else, logged).
Update is full-replace: title/description are required, and omitting
short_description or category nulls them — exactly what the legacy controller did.
One internal hardening (wire unchanged): legacy deleted the old thumbnail before
uploading the new one, so a failed upload lost the old image. The rebuild uploads
the new one first, writes, and only then deletes the old asset by URL — and
compensates a failed write by deleting the fresh upload (the Phase 7 media-pipeline
pattern, via MediaStoragePort.deleteByUrl).
The like toggle (POST /api/blogs/like) is a direct clone of the Phase 7 soft-delete
toggle: create / restore-the-same-row / soft-delete in a transaction with one bounded
unique-violation retry, likes_count excluding trashed rows — and the two distinct
messages (Blog liked successfully. / Blog unliked successfully.). The §7 doc
shows a combined sample string; the controller's two strings govern.
Comments — order matters, and the asymmetry is the point
One level of threading (parent_id), top-level-only public list (replies_count from
one grouped query — no N+1), no replies endpoint, no nested tree. The interesting part
is the update/delete split, preserved exactly:
| lookup | foreign comment | missing comment | |
|---|---|---|---|
PATCH /blogs/comments/{comment} |
by id (route-model binding) | 403 Unauthorized (periodless) |
404 Comment not found. |
DELETE /blogs/comments/{id} |
by id AND owner | 404 Comment not found. |
404 Comment not found. |
Update's check order is observable: legacy resolved the comment and checked
ownership before validating the body, so a foreign comment with an invalid body
answers 403, not 422. A class-validator DTO would fire first and break that — so the
update body is deliberately not DTO-validated; the service validates after the
ownership check (emitting the standard keyed-422 shape with class-validator-style
strings). Two more frozen mismatches: create allows body up to 10000 chars but
update only min:1|max:2000 (§8 governs; a legacy frontend doc claims otherwise and
is wrong), and the update success message has no trailing period —
Comment updated successfully.
Two deliberate divergences from the Phase 7 comment surface, both legacy-faithful:
the author block falls back to null (not '') for id/employer_id/
applicant_id, and parent_id passes through as raw null (posts cast it to 0).
And the big conscious decision: the blog domain has no deactivated-author scope
anywhere — legacy intentionally skipped the filter PostComment has
(BLOG_LIKE_COMMENT_SAFETY §2.5), so a deactivated user's blog comments stay visible
and counted. Recorded in ADR-0032; revisit only as a deliberate moderation pass.
Testing
Five TDD slices built the module (resources → reads/writes/comments fanned out, the
like toggle independent), each at 100% file coverage. Two e2e suites drive the live
app — test/blog.e2e-spec.ts (CRUD, view-counter dedup arithmetic, thumbnail
pipeline, ownership 403s, like cycle) and test/blog-comments.e2e-spec.ts (the full
asymmetry matrix, the periodless message, the no-scope parity with a
deactivated-author seed) — authored through the same adversarial
author→review→patch pipeline as Phase 7, on isolated databases. The Postman
complete-flow collection gained a 17-blog folder.
Subpages