Skip to content

feat(api): add GET /content/post/:slug endpoint#174

Open
bbornino wants to merge 1 commit into
playfulprogramming:mainfrom
bbornino:feat/post-by-slug-endpoint
Open

feat(api): add GET /content/post/:slug endpoint#174
bbornino wants to merge 1 commit into
playfulprogramming:mainfrom
bbornino:feat/post-by-slug-endpoint

Conversation

@bbornino

@bbornino bbornino commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Closes #80.

Adds GET /content/post/:slug?locale=en (default en), returning a single post's data along with its author list and "chapter list."

Response shape

  • Post fields: slug, title, description, banner/social image URLs, word count, published date
  • authors: id, name, profile image URL (joined via post_authors)
  • chapters: sibling posts in the same collection, ordered by collectionOrder, each flagged with isCurrent

There's no chapters table in the schema — "chapters" are just sibling posts sharing a collectionSlug, which is how collections.ts already treats them (chapterCount).

publishedAt filtering

post_data.publishedAt is nullable at the schema level, but every current write path (sync-post) always sets it, so there's no existing filtering precedent anywhere else in the codebase. I added filtering here anyway:

  • The main post returns 404 if its post_data row has publishedAt: null for the requested locale (same 404 as "no row for locale").
  • The chapters list excludes any sibling whose own post_data row has publishedAt: null — otherwise a published chapter's response would leak the title/slug of unpublished siblings in the same collection, which nothing else in the codebase currently exposes.

This is filtered at the app level (post-fetch) rather than in the Drizzle where clause — fine for a single-row + small-sibling-list endpoint like this; could move to a DB-level filter later if collections grow large enough for it to matter.

Flagging this as a judgment call in case the intended gate is different (e.g. drafts visible to authenticated authors) — happy to adjust based on feedback.

Testing

  • New post.test.ts: 6 tests covering happy path (with chapters + isCurrent), no-collection case, missing-locale-data 404, unpublished-main-post 404, unpublished-sibling-excluded, and post-not-found 404.
  • pnpm test:unit: all 24 targets pass.
  • pnpm prettier (check): clean except a pre-existing, unrelated flag on .claude/settings.local.json.

Summary by CodeRabbit

  • New Features

    • Added support for viewing individual content posts through the app’s content API, including related chapters, author details, and localized post information.
  • Bug Fixes

    • Improved handling for missing or unavailable posts, returning a clear not-found response when content can’t be displayed.
    • Filtered chapter listings so only published items appear, with chapters shown in the correct order.

Closes playfulprogramming#80. Returns a single post's data, author list, and collection
chapter list (sibling posts ordered by collectionOrder, with the
current post flagged via isCurrent), following collections.ts's
schema and query conventions.

Filters out unpublished posts/chapters (post_data.publishedAt: null)
at both the main lookup and the chapter list, to avoid leaking draft
slugs and titles via the chapter list to anyone who doesn't already
know they exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new Fastify GET /content/post/:slug endpoint that queries a post with its authors and collection chapters, returning a typed JSON response or a 404 error. Registers the route in createApp, adds a test suite for the endpoint, and extends test mock setup with db.query.posts.findFirst.

Changes

Post Endpoint

Layer / File(s) Summary
Request/response schemas
apps/api/src/routes/content/post.ts
TypeBox schemas defined for route params, query (with default locale), success response with examples, and 404 error response.
Route registration and handler
apps/api/src/routes/content/post.ts
Registers GET /content/post/:slug with documented schema metadata; handler queries posts with nested authors/collection data, validates publishedAt, builds sorted/filtered chapters, converts image URLs, and returns 200/404 JSON responses.
App wiring
apps/api/src/createApp.ts
Imports postRoutes and registers it with the Fastify app.
Tests and mock setup
apps/api/src/routes/content/post.test.ts, apps/api/test-utils/setup.ts
Adds db.query.posts.findFirst mock and a test suite covering success and 404 scenarios with inline snapshots.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant postRoutes
  participant Database

  Client->>postRoutes: GET /content/post/:slug?locale
  postRoutes->>Database: db.query.posts.findFirst (authors, collection posts)
  Database-->>postRoutes: post record or undefined
  alt post missing or unpublished
    postRoutes-->>Client: 404 { error: "Post not found" }
  else post found
    postRoutes->>postRoutes: filter/sort collection posts into chapters
    postRoutes->>postRoutes: build image URLs, format publishedAt
    postRoutes-->>Client: 200 with post, authors, chapters
  end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

Possibly related PRs

  • playfulprogramming/hoof#120: Both PRs modify apps/api/src/createApp.ts to register new content route plugins (postRoutes vs collectionsRoutes) and add corresponding route modules and tests.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately highlights the main change: a new GET /content/post/:slug endpoint.
Linked Issues check ✅ Passed The PR implements the requested post page endpoint with author and chapter data, matching issue #80's core objective.
Out of Scope Changes check ✅ Passed All changes support the new post endpoint and its tests; no unrelated scope is evident.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/content/post.ts`:
- Line 128: The authors mapping in the post response is currently relying on
database return order, which is not stable. Update the post retrieval/mapping in
the post route to add an explicit orderBy for authors (or otherwise define a
deterministic sort) when populating post.authors, using the existing authors
relation in the response shape so the author order is consistent.
- Around line 114-143: The post query is pulling `data` rows only by locale,
which can return multiple versions for the same slug and make
`post.data[0]`/`chapter.data[0]` nondeterministic. Update the
`db.query.posts.findFirst` relations in `post.ts` so both the top-level `data`
and nested `collection.posts.data` filters include the intended `version`
alongside `locale`. Use the existing `post.data` and `chapter.data` relation
selectors to locate the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 165375d1-9075-4126-b152-8e80943bf7e4

📥 Commits

Reviewing files that changed from the base of the PR and between 5b1c106 and 27a34d4.

📒 Files selected for processing (4)
  • apps/api/src/createApp.ts
  • apps/api/src/routes/content/post.test.ts
  • apps/api/src/routes/content/post.ts
  • apps/api/test-utils/setup.ts

Comment on lines +114 to +143
const post = await db.query.posts.findFirst({
where: { slug },
with: {
data: {
columns: {
title: true,
description: true,
bannerImage: true,
socialImage: true,
wordCount: true,
publishedAt: true,
},
where: { locale },
},
authors: { columns: { slug: true, name: true, profileImage: true } },
collection: {
with: {
posts: {
columns: { slug: true, collectionOrder: true },
with: {
data: {
columns: { title: true, publishedAt: true },
where: { locale },
},
},
},
},
},
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant route file with line numbers
sed -n '1,240p' apps/api/src/routes/content/post.ts

echo '--- FILES ---'
# Locate post_data schema and related usages
git ls-files | rg 'apps/api/src/.*(schema|db|table|drizzle|content).*|apps/api/src/routes/content|post_data|postData|version'

Repository: playfulprogramming/hoof

Length of output: 5021


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the route file structure to identify relevant functions/relations
ast-grep outline apps/api/src/routes/content/post.ts --view expanded || true

echo '--- SEARCH version filters/usages ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  '\bversion\b|post_data|postData|published version|draft' apps/api/src || true

echo '--- SEARCH data relation patterns ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'with:\s*\{\s*data:\s*\{' apps/api/src || true

Repository: playfulprogramming/hoof

Length of output: 1863


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the schema and the route body in smaller slices.
echo '--- post.ts lines 100-170 ---'
sed -n '100,170p' apps/api/src/routes/content/post.ts

echo '--- schema candidates ---'
fd -a 'post*' apps/api/src || true
fd -a '*schema*' apps/api/src || true
fd -a '*table*' apps/api/src || true

Repository: playfulprogramming/hoof

Length of output: 2748


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- locate db schema and relation definitions ---'
git ls-files | rg '(^|/)(schema|schemas|table|tables|db|drizzle).*|packages/.*/src/.*(schema|table|db).*|.*post_data.*|.*postData.*|.*version.*'

echo '--- search for post_data / version definitions ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'post_data|postData|version: text\("version"\)|primaryKey\(\{ columns: \[table\.slug, table\.locale, table\.version\]\}\)|where: \{ locale \}|orderBy' .

echo '--- search for data relation in db package ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'relations\(|data:\s*\(|many\(.*post_data|one\(.*post_data|posts.*data' .

Repository: playfulprogramming/hoof

Length of output: 12920


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- packages/db/src/schema/posts.ts ---'
sed -n '1,220p' packages/db/src/schema/posts.ts

echo '--- packages/db/src/relations.ts ---'
sed -n '1,220p' packages/db/src/relations.ts

echo '--- apps/worker/src/tasks/sync-post/processor.ts (relevant slice) ---'
sed -n '120,220p' apps/worker/src/tasks/sync-post/processor.ts

echo '--- version assignments across repo ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  '\bversion\s*:\s*["'\'']' apps packages | sed -n '1,200p'

Repository: playfulprogramming/hoof

Length of output: 5752


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- post.test.ts slices around fixtures and assertions ---'
sed -n '1,340p' apps/api/src/routes/content/post.test.ts

echo '--- search for postData version in tests/fixtures ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'postData|version:' apps/api/src/routes/content/post.test.ts apps/worker/src/tasks/sync-post/processor.test.ts apps/worker/test-utils/setup.ts

Repository: playfulprogramming/hoof

Length of output: 7989


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- search for parsed.version and content version semantics ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'parsed\.version|frontmatter.*version|version\s*:' apps/worker apps/api packages/post-images packages/db | sed -n '1,220p'

echo '--- inspect sync-post parser-related code ---'
fd -a 'processor.ts' apps/worker/src/tasks/sync-post
fd -a '*parse*' apps/worker/src/tasks/sync-post apps/worker/src | sed -n '1,120p'

Repository: playfulprogramming/hoof

Length of output: 1297


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- locate markdown parser / sync-post inputs ---'
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' \
  'sync-post|parsed\.title|parsed\.published|parsed\.version|upToDateSlug|noindex|license' apps/worker/src | sed -n '1,260p'

Repository: playfulprogramming/hoof

Length of output: 1905


Filter post_data by version here. The worker writes parsed.version into post_data, so where: { locale } can return multiple rows for the same slug/locale. post.data[0] and chapter.data[0] then become order-dependent; add version: "" (or the published version you intend) to both data relations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/content/post.ts` around lines 114 - 143, The post query
is pulling `data` rows only by locale, which can return multiple versions for
the same slug and make `post.data[0]`/`chapter.data[0]` nondeterministic. Update
the `db.query.posts.findFirst` relations in `post.ts` so both the top-level
`data` and nested `collection.posts.data` filters include the intended `version`
alongside `locale`. Use the existing `post.data` and `chapter.data` relation
selectors to locate the fix.

},
where: { locale },
},
authors: { columns: { slug: true, name: true, profileImage: true } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's an order/position column on the post_authors join table
ast-grep run --pattern 'export const postAuthors = pgTable($_, $_, $_)' --lang typescript packages/db
rg -n -A 15 'postAuthors' packages/db/src/schema --type ts

Repository: playfulprogramming/hoof

Length of output: 2118


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== apps/api/src/routes/content/post.ts =="
ast-grep outline apps/api/src/routes/content/post.ts --view expanded
echo
sed -n '1,240p' apps/api/src/routes/content/post.ts

echo
echo "== packages/db/src/schema/posts.ts =="
ast-grep outline packages/db/src/schema/posts.ts --view expanded
echo
sed -n '1,220p' packages/db/src/schema/posts.ts

echo
echo "== search for authors ordering / post.authors usage =="
rg -n "post\.authors|authors: \{|orderBy:|author.*order" apps packages --type ts

Repository: playfulprogramming/hoof

Length of output: 7859


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relation definitions around posts =="
rg -n -A 80 -B 20 'relations\(|postAuthors|authors:' packages/db/src/schema/posts.ts packages/db/src/schema/*.ts

echo
echo "== relation helpers / docs references to orderBy in db schema =="
rg -n "orderBy" packages/db/src/schema packages/db/src -g '*.ts'

Repository: playfulprogramming/hoof

Length of output: 6930


Add an explicit author sort If author order matters in the response, add an explicit orderBy for authors; post.authors is mapped in whatever order the DB returns, and post_authors has no position column to make that stable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/content/post.ts` at line 128, The authors mapping in the
post response is currently relying on database return order, which is not
stable. Update the post retrieval/mapping in the post route to add an explicit
orderBy for authors (or otherwise define a deterministic sort) when populating
post.authors, using the existing authors relation in the response shape so the
author order is consistent.

Comment on lines +34 to +35
collectionOrder: Type.Number(),
isCurrent: Type.Boolean(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two requests:

  • Could we remove these two properties (collectionOrder and isCurrent)? The frontend should be able to infer both from the data.
  • Can we add the collection slug & title to the response? (I would group these in an object; i.e. collection.slug, collection.title, collection.chapters are all required when the collection is present)

Otherwise, this looks great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Endpoint for get post by ID

2 participants