CODEX // oaoisme wiki

§ unswayed-backend

Blog (Phase 8)

updated 2026-06-10

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-wide pagination key. 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_liked entirely (legacy never loaded them; ->when(isset) hid the keys). The detail includes the counts always and is_liked only when the request is authenticated — the route uses the optional-auth guard, so a guest's response simply has no is_liked key.
  • full_article is an alias of description — 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 periodComment 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