Skip to content

feat(api): add GET /content/posts list endpoint#184

Merged
fennifith merged 4 commits into
playfulprogramming:mainfrom
bbornino:feat/list-posts-endpoint
Jul 14, 2026
Merged

feat(api): add GET /content/posts list endpoint#184
fennifith merged 4 commits into
playfulprogramming:mainfrom
bbornino:feat/list-posts-endpoint

Conversation

@bbornino

@bbornino bbornino commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #83. Adds a paginated GET /content/posts endpoint returning post-card data: banner, title, authors, word count, publish date, and tags.

  • Pagination: page/limit, 0-indexed to match /content/profiles and /content/collections. page defaults to 0, limit defaults to 20 with a maximum of 100.
  • Sort: sort=newest (default, descending by publishedAt) or sort=oldest (ascending).
  • Author filter: author=<slug> matches anywhere in a post's co-author list, not just a primary author, via an EXISTS subquery against post_authors.
  • Tags: aggregated from the post_tags join table (added in Create a tags table and relations for posts/collections #81) via the core Drizzle query builder (leftJoin + groupBy + array_agg), mirroring the pattern from Add posts and sortBy=posts to /content/profiles #176's profiles post-count work.
  • No excerpt/description: per discussion, excerpt/description is only used internally to backfill a missing description at sync time and is intentionally not part of this response.
  • Word count: already computed and persisted by sync-post (postData.wordCount) — no migration or worker changes needed here, this endpoint just reads the existing column.

Schema refactor

Extracted a shared PostBaseSchema (slug, title, bannerUrl, wordCount, publishedAt, authors) so the #80 post-detail schema and this new list-item schema compose from common fields via Type.Intersect instead of duplicating them. Note: TypeBox's Type.Composite isn't actually available in the installed typebox version (only exists in @sinclair/typebox 0.34.x per the readme's legacy migration notes) — Type.Intersect is the real composition primitive here. Verified with a throwaway Fastify test that the compiled fast-json-stringify serializer correctly flattens the Intersect/allOf schema (both branches' fields present, undeclared fields stripped) rather than silently falling back to plain JSON.stringify.

Query design

Author objects for the response are fetched via a small second query (postAuthors + profiles for the page's post slugs) rather than folded into the main aggregate query, to avoid needing json_agg of multi-field objects alongside the tags array_agg.

Open design question: postData version selection

postData is keyed by (slug, locale, version), and sync-post writes one row per version, so a naive join on just slug+locale can return duplicate rows for a post that has more than one version. This PR pins the join to version = "" to resolve that — matching the schema's declared default and what every existing fixture assumes today.

Flagging rather than assuming this is settled: there's no documented "current version" convention anywhere in the query layer today. #80's post.ts has the same latent gap — it doesn't filter by version at all, it just takes data[0] from an unordered, unfiltered result. If Playful Programming's post-rewrite/versioning feature ever populates a real non-empty version for live content, this filter and post.ts's data[0] pick will both need revisiting together, ideally by establishing one real convention (e.g. an isCurrent flag, or "max version wins") rather than each endpoint guessing independently. See linked issue once opened.

Test plan

  • pnpm test:unit passes (sherif, knip, eslint, publint, vitest across all projects)
  • pnpm prettier check clean (aside from the pre-existing .claude/settings.local.json exclusion)
  • New posts.test.ts: pagination (including bounds/defaults), sort default/oldest, author co-author filtering via EXISTS, tag aggregation, multi-author grouping, empty results, and confirms no description/excerpt field leaks into the response
  • post.test.ts still passes unchanged after the schema refactor

Summary by CodeRabbit

  • New Features

    • Added a posts content endpoint for browsing published posts.
    • Supports pagination, newest/oldest sorting, locale selection, and author filtering.
    • Post listings include titles, slugs, images, publication dates, word counts, authors, and tags.
  • Bug Fixes

    • Ensured only published, indexable posts appear in listings.
  • Tests

    • Added coverage for filtering, sorting, pagination, author grouping, and empty results.

Adds a paginated post-card endpoint (playfulprogramming#83) with newest/oldest sort,
co-author filtering, and tags aggregated from post_tags. Extracts a
shared PostBaseSchema so playfulprogramming#80's post-detail schema and this list-item
schema compose from common fields instead of duplicating them.

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

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fennifith, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1fbf46e1-ab7f-407d-9162-bccb895ebf0e

📥 Commits

Reviewing files that changed from the base of the PR and between 3fea845 and ecfd31b.

📒 Files selected for processing (1)
  • apps/api/test-utils/setup.ts
📝 Walkthrough

Walkthrough

This PR adds GET /content/posts, returning paginated, sortable, filterable posts with authors and tags through Drizzle queries. It extracts a shared PostBaseSchema for reuse by the single-post route, registers the new route, and adds tests and database mocks.

Changes

Posts List Endpoint

Layer / File(s) Summary
Shared post base schema and single-post route refactor
apps/api/src/routes/content/postBaseSchema.ts, apps/api/src/routes/content/post.ts
Adds common post fields through PostBaseSchema and composes the single-post response with description, social image, and collection fields.
Posts list route implementation
apps/api/src/routes/content/posts.ts
Adds GET /content/posts with query validation, pagination, sorting, publication/indexing filters, author filtering, tag aggregation, author grouping, and response mapping.
App registration
apps/api/src/createApp.ts
Registers postsRoutes with the Fastify application.
Tests and mock setup
apps/api/src/routes/content/posts.test.ts, apps/api/test-utils/setup.ts
Adds route tests and expands database mocks for post, tag, post-data, and select query support.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant postsRoutes
  participant Drizzle
  participant Database
  Client->>postsRoutes: GET /content/posts with query parameters
  postsRoutes->>Drizzle: Query filtered and paginated post rows
  Drizzle->>Database: Join postData and postTags
  Database-->>Drizzle: Post rows with aggregated tags
  postsRoutes->>Drizzle: Query authors for returned post slugs
  Drizzle->>Database: Fetch author and profile fields
  Database-->>Drizzle: Author rows
  postsRoutes->>postsRoutes: Group authors and map response cards
  postsRoutes-->>Client: 200 PostsResponse
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The endpoint matches pagination, sorting, author filtering, and implicit exclusion rules, but it explicitly omits excerpt, which issue #83 requires. Include excerpt in the post-card response (and ensure the listed card fields match issue #83), or update the issue requirements if excerpt is no longer desired.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding the GET /content/posts list endpoint.
Out of Scope Changes check ✅ Passed The changes are centered on the new posts list endpoint, shared schema extraction, route registration, and supporting tests/setup.
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

🧹 Nitpick comments (1)
apps/api/src/routes/content/posts.ts (1)

101-102: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider conditional postAuthors join when no author filter is applied.

The leftJoin(postAuthors) is always applied, even when no author filter is present. This produces N rows per post (one per author), creating a cross product with postTags rows that groupBy must collapse. For a post with 5 tags and 3 authors, that's 15 rows instead of 5. Using a subquery or EXISTS for the author filter, or conditionally applying the join, would avoid the unnecessary cross product.

🤖 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/posts.ts` around lines 101 - 102, The `getPosts`
query in `posts.ts` always applies the `leftJoin(postAuthors)` even when no
author filter is provided, which creates an unnecessary tag/author row explosion
before `groupBy` collapses it. Update the query builder so `postAuthors` is only
joined when the `author` filter is actually used, or replace that join with an
`EXISTS`/subquery-based author filter; keep the existing `postTags` and
`groupBy` behavior intact.
🤖 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/posts.ts`:
- Around line 17-18: The query schema for the posts route currently makes page
and limit required and leaves limit unbounded. Update the schema in the content
posts route so the request handler for GET /content/posts uses defaults of
page=1 and limit=20, and add a maximum constraint to limit to prevent oversized
requests. Make the change in the schema definitions used by the posts route so
the route behavior matches the pagination defaults expected by the handler.
- Around line 96-102: Select a single postData version per post/locale in the
posts query, since the current posts -> postData inner join on slug and locale
can duplicate rows when multiple published versions exist. Update the join logic
in the query that builds the posts list to constrain postData to the intended
version (for example by joining through a version-aware subquery or adding the
version filter used by the sync job) before the later grouping/sorting, and keep
the change localized to the route handler that assembles the posts query.

---

Nitpick comments:
In `@apps/api/src/routes/content/posts.ts`:
- Around line 101-102: The `getPosts` query in `posts.ts` always applies the
`leftJoin(postAuthors)` even when no author filter is provided, which creates an
unnecessary tag/author row explosion before `groupBy` collapses it. Update the
query builder so `postAuthors` is only joined when the `author` filter is
actually used, or replace that join with an `EXISTS`/subquery-based author
filter; keep the existing `postTags` and `groupBy` behavior intact.
🪄 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: b42b80f2-ff7f-4749-9add-9a44fc6c12b2

📥 Commits

Reviewing files that changed from the base of the PR and between 086e182 and 398f476.

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

Comment thread apps/api/src/routes/content/posts.ts Outdated
Comment thread apps/api/src/routes/content/posts.ts Outdated
…fulprogramming#184)

Bound pagination (page defaults to 0, limit defaults to 20 with a max
of 100), pin the postData join to version="" to stop duplicate rows
when a slug/locale has multiple versions, and replace the always-on
postAuthors join with a conditional EXISTS filter so it no longer
fans out the postTags aggregation when no author filter is given.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fennifith fennifith enabled auto-merge July 14, 2026 01:08
@fennifith fennifith added this pull request to the merge queue Jul 14, 2026
Merged via the queue into playfulprogramming:main with commit 8188426 Jul 14, 2026
4 checks passed
fennifith pushed a commit that referenced this pull request Jul 14, 2026
Bound pagination (page defaults to 0, limit defaults to 20 with a max
of 100), pin the postData join to version="" to stop duplicate rows
when a slug/locale has multiple versions, and replace the always-on
postAuthors join with a conditional EXISTS filter so it no longer
fans out the postTags aggregation when no author filter is given.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 to return a list of posts

2 participants