From 398f476fe3aa0b123798aa2694ef2355c431c003 Mon Sep 17 00:00:00 2001 From: Brian Bornino Date: Wed, 8 Jul 2026 08:27:57 -0700 Subject: [PATCH 1/3] feat(api): add GET /content/posts list endpoint Adds a paginated post-card endpoint (#83) with newest/oldest sort, co-author filtering, and tags aggregated from post_tags. Extracts a shared PostBaseSchema so #80's post-detail schema and this list-item schema compose from common fields instead of duplicating them. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/createApp.ts | 2 + apps/api/src/routes/content/post.ts | 50 ++- apps/api/src/routes/content/postBaseSchema.ts | 17 ++ apps/api/src/routes/content/posts.test.ts | 284 ++++++++++++++++++ apps/api/src/routes/content/posts.ts | 167 ++++++++++ apps/api/test-utils/setup.ts | 28 ++ 6 files changed, 519 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/routes/content/postBaseSchema.ts create mode 100644 apps/api/src/routes/content/posts.test.ts create mode 100644 apps/api/src/routes/content/posts.ts diff --git a/apps/api/src/createApp.ts b/apps/api/src/createApp.ts index 0f4dae64..2da7e672 100644 --- a/apps/api/src/createApp.ts +++ b/apps/api/src/createApp.ts @@ -8,6 +8,7 @@ import urlMetadataRoutes from "./routes/tasks/url-metadata.ts"; import authorsRoutes from "./routes/content/authors.ts"; import collectionsRoutes from "./routes/content/collections.ts"; import postRoutes from "./routes/content/post.ts"; +import postsRoutes from "./routes/content/posts.ts"; import profilesRoutes from "./routes/content/profiles.ts"; import fastify from "fastify"; import devRoutes from "./routes/dev/index.ts"; @@ -26,6 +27,7 @@ export const createApp = () => { app.register(authorsRoutes); app.register(collectionsRoutes); app.register(postRoutes); + app.register(postsRoutes); app.register(profilesRoutes); if (env.ENVIRONMENT === "development") { diff --git a/apps/api/src/routes/content/post.ts b/apps/api/src/routes/content/post.ts index e239bd51..01629a65 100644 --- a/apps/api/src/routes/content/post.ts +++ b/apps/api/src/routes/content/post.ts @@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from "fastify"; import { db } from "@playfulprogramming/db"; import { Type, type Static } from "typebox"; import { createImageUrl } from "../../utils.ts"; +import { PostBaseSchema } from "./postBaseSchema.ts"; const PostParamsSchema = Type.Object({ slug: Type.String(), @@ -11,35 +12,26 @@ const PostQueryParamsSchema = Type.Object({ locale: Type.String({ default: "en" }), }); -const PostResponseSchema = Type.Object( - { - slug: Type.String(), - title: Type.String(), - description: Type.String(), - bannerUrl: Type.Optional(Type.String()), - socialImageUrl: Type.Optional(Type.String()), - wordCount: Type.Number(), - publishedAt: Type.Optional(Type.String({ format: "date-time" })), - authors: Type.Array( - Type.Object({ - id: Type.String(), - name: Type.String(), - profileImageUrl: Type.Optional(Type.String()), - }), - ), - collection: Type.Optional( - Type.Object({ - slug: Type.String(), - title: Type.String(), - chapters: Type.Array( - Type.Object({ - slug: Type.String(), - title: Type.String(), - }), - ), - }), - ), - }, +const PostResponseSchema = Type.Intersect( + [ + PostBaseSchema, + Type.Object({ + description: Type.String(), + socialImageUrl: Type.Optional(Type.String()), + collection: Type.Optional( + Type.Object({ + slug: Type.String(), + title: Type.String(), + chapters: Type.Array( + Type.Object({ + slug: Type.String(), + title: Type.String(), + }), + ), + }), + ), + }), + ], { examples: [ { diff --git a/apps/api/src/routes/content/postBaseSchema.ts b/apps/api/src/routes/content/postBaseSchema.ts new file mode 100644 index 00000000..63fc81aa --- /dev/null +++ b/apps/api/src/routes/content/postBaseSchema.ts @@ -0,0 +1,17 @@ +import { Type } from "typebox"; + +/** Fields common to every post representation returned by the content API. */ +export const PostBaseSchema = Type.Object({ + slug: Type.String(), + title: Type.String(), + bannerUrl: Type.Optional(Type.String()), + wordCount: Type.Number(), + publishedAt: Type.Optional(Type.String({ format: "date-time" })), + authors: Type.Array( + Type.Object({ + id: Type.String(), + name: Type.String(), + profileImageUrl: Type.Optional(Type.String()), + }), + ), +}); diff --git a/apps/api/src/routes/content/posts.test.ts b/apps/api/src/routes/content/posts.test.ts new file mode 100644 index 00000000..73bc492e --- /dev/null +++ b/apps/api/src/routes/content/posts.test.ts @@ -0,0 +1,284 @@ +import fastify, { type FastifyInstance } from "fastify"; +import postsRoutes from "./posts.ts"; +import { db, postAuthors, postData } from "@playfulprogramming/db"; +import { and, asc, desc, eq, isNotNull } from "drizzle-orm"; + +function mockPostsSelectChain(rows: unknown[]) { + const offset = vi.fn().mockResolvedValue(rows); + const limit = vi.fn().mockReturnValue({ offset }); + const orderBy = vi.fn().mockReturnValue({ limit }); + const groupBy = vi.fn().mockReturnValue({ orderBy }); + const where = vi.fn().mockReturnValue({ groupBy }); + const leftJoinPostAuthors = vi.fn().mockReturnValue({ where }); + const leftJoinPostTags = vi + .fn() + .mockReturnValue({ leftJoin: leftJoinPostAuthors }); + const innerJoinPostData = vi + .fn() + .mockReturnValue({ leftJoin: leftJoinPostTags }); + const from = vi.fn().mockReturnValue({ innerJoin: innerJoinPostData }); + + return { + from, + innerJoinPostData, + leftJoinPostTags, + leftJoinPostAuthors, + where, + groupBy, + orderBy, + limit, + offset, + }; +} + +function mockAuthorsSelectChain(rows: unknown[]) { + const where = vi.fn().mockResolvedValue(rows); + const innerJoin = vi.fn().mockReturnValue({ where }); + const from = vi.fn().mockReturnValue({ innerJoin }); + + return { from, innerJoin, where }; +} + +function mockDbSelect(postsRows: unknown[], authorRows: unknown[] = []) { + const postsChain = mockPostsSelectChain(postsRows); + const authorsChain = mockAuthorsSelectChain(authorRows); + + const selectMock = vi.mocked(db.select); + selectMock.mockReturnValueOnce({ from: postsChain.from } as never); + if (postsRows.length > 0) { + selectMock.mockReturnValueOnce({ from: authorsChain.from } as never); + } + + return { postsChain, authorsChain }; +} + +describe("Posts Routes Tests", () => { + let app: FastifyInstance; + beforeAll(async () => { + app = fastify(); + await app.register(postsRoutes); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("/content/posts", () => { + test("returns posts with their authors and tags", async () => { + mockDbSelect( + [ + { + slug: "example-post", + title: "Example Post", + bannerImage: "content/banner.png", + wordCount: 1200, + publishedAt: new Date("2024-01-15T00:00:00Z"), + tags: ["react", "javascript"], + }, + ], + [ + { + postSlug: "example-post", + id: "crutchcorn", + name: "Corbin Crutchley", + profileImage: "content/profile.png", + }, + ], + ); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchInlineSnapshot(` + [ + { + "authors": [ + { + "id": "crutchcorn", + "name": "Corbin Crutchley", + "profileImageUrl": "https://s3_public_url.test/s3_bucket/content/profile.png", + }, + ], + "bannerUrl": "https://s3_public_url.test/s3_bucket/content/banner.png", + "publishedAt": "2024-01-15T00:00:00.000Z", + "slug": "example-post", + "tags": [ + "react", + "javascript", + ], + "title": "Example Post", + "wordCount": 1200, + }, + ] + `); + }); + + test("does not include a description or excerpt field", async () => { + mockDbSelect([ + { + slug: "example-post", + title: "Example Post", + bannerImage: null, + wordCount: 300, + publishedAt: new Date("2024-01-15T00:00:00Z"), + tags: [], + }, + ]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + const [post] = response.json(); + expect(post).not.toHaveProperty("description"); + expect(post).not.toHaveProperty("excerpt"); + }); + + test("groups multiple co-authors under the same post", async () => { + mockDbSelect( + [ + { + slug: "example-post", + title: "Example Post", + bannerImage: null, + wordCount: 300, + publishedAt: new Date("2024-01-15T00:00:00Z"), + tags: [], + }, + ], + [ + { + postSlug: "example-post", + id: "crutchcorn", + name: "Corbin Crutchley", + profileImage: null, + }, + { + postSlug: "example-post", + id: "fennifith", + name: "James Fenn", + profileImage: null, + }, + ], + ); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + expect( + response.json()[0].authors.map((a: { id: string }) => a.id), + ).toEqual(["crutchcorn", "fennifith"]); + }); + + test("returns an empty list when there are no posts", async () => { + mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchInlineSnapshot(`[]`); + }); + + test("paginates using page and limit query params", async () => { + const { postsChain } = mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "2", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + expect(postsChain.limit).toBeCalledWith(10); + expect(postsChain.offset).toBeCalledWith(20); + }); + + test("defaults to sort=newest, ordering by publishedAt descending", async () => { + const { postsChain } = mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + expect(postsChain.orderBy).toBeCalledWith(desc(postData.publishedAt)); + }); + + test("sort=oldest orders by publishedAt ascending", async () => { + const { postsChain } = mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10", sort: "oldest" }, + }); + + expect(response.statusCode).toBe(200); + expect(postsChain.orderBy).toBeCalledWith(asc(postData.publishedAt)); + }); + + test("only includes posts with a publishedAt date and noindex false", async () => { + const { postsChain } = mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10" }, + }); + + expect(response.statusCode).toBe(200); + expect(postsChain.where).toBeCalledWith( + and(isNotNull(postData.publishedAt), eq(postData.noindex, false)), + ); + }); + + test("author filter matches co-authors via the postAuthors join", async () => { + const { postsChain } = mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10", author: "crutchcorn" }, + }); + + expect(response.statusCode).toBe(200); + expect(postsChain.where).toBeCalledWith( + and( + isNotNull(postData.publishedAt), + eq(postData.noindex, false), + eq(postAuthors.authorSlug, "crutchcorn"), + ), + ); + }); + + test("returns an empty list for an author with no posts", async () => { + mockDbSelect([]); + + const response = await app.inject({ + method: "GET", + url: "/content/posts", + query: { page: "0", limit: "10", author: "non-existent-author" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchInlineSnapshot(`[]`); + }); + }); +}); diff --git a/apps/api/src/routes/content/posts.ts b/apps/api/src/routes/content/posts.ts new file mode 100644 index 00000000..b6b62f20 --- /dev/null +++ b/apps/api/src/routes/content/posts.ts @@ -0,0 +1,167 @@ +import type { FastifyPluginAsync } from "fastify"; +import { + db, + posts, + postData, + postTags, + postAuthors, + profiles, +} from "@playfulprogramming/db"; +import { and, asc, desc, eq, inArray, isNotNull, sql } from "drizzle-orm"; +import { Type, type Static } from "typebox"; +import { createImageUrl } from "../../utils.ts"; +import { PostBaseSchema } from "./postBaseSchema.ts"; + +const PostsQueryParamsSchema = Type.Object({ + locale: Type.String({ default: "en" }), + page: Type.Number({ minimum: 0 }), + limit: Type.Number({ minimum: 1 }), + sort: Type.Union([Type.Literal("newest"), Type.Literal("oldest")], { + default: "newest", + }), + author: Type.Optional(Type.String()), +}); + +const PostsResponseSchema = Type.Array( + Type.Intersect( + [PostBaseSchema, Type.Object({ tags: Type.Array(Type.String()) })], + { + examples: [ + { + slug: "example-post", + title: "Example Post", + bannerUrl: "https://example.test/banner.png", + wordCount: 1200, + publishedAt: "2024-01-15T00:00:00.000Z", + authors: [ + { + id: "crutchcorn", + name: "Corbin Crutchley", + profileImageUrl: "https://example.test/profile.jpg", + }, + ], + tags: ["react", "javascript"], + }, + ], + }, + ), +); + +type PostsResponse = Static; + +type PostAuthorEntry = PostsResponse[number]["authors"][number]; + +const postsRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: Static; + Reply: PostsResponse; + }>( + "/content/posts", + { + schema: { + description: "Fetch a paginated list of posts for post-card display", + querystring: PostsQueryParamsSchema, + response: { + 200: { + description: "Successful", + content: { + "application/json": { schema: PostsResponseSchema }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { locale, page, limit, sort, author } = request.query; + + const conditions = [ + isNotNull(postData.publishedAt), + eq(postData.noindex, false), + ]; + if (author) { + conditions.push(eq(postAuthors.authorSlug, author)); + } + + const rows = await db + .select({ + slug: posts.slug, + title: postData.title, + bannerImage: postData.bannerImage, + wordCount: postData.wordCount, + publishedAt: postData.publishedAt, + tags: sql< + string[] + >`coalesce(array_agg(distinct ${postTags.tag}) filter (where ${postTags.tag} is not null), '{}')`, + }) + .from(posts) + .innerJoin( + postData, + and(eq(postData.slug, posts.slug), eq(postData.locale, locale)), + ) + .leftJoin(postTags, eq(postTags.postSlug, posts.slug)) + .leftJoin(postAuthors, eq(postAuthors.postSlug, posts.slug)) + .where(and(...conditions)) + .groupBy( + posts.slug, + postData.title, + postData.bannerImage, + postData.wordCount, + postData.publishedAt, + ) + .orderBy( + sort === "oldest" + ? asc(postData.publishedAt) + : desc(postData.publishedAt), + ) + .limit(limit) + .offset(page * limit); + + const slugs = rows.map((row) => row.slug); + + const authorRows = slugs.length + ? await db + .select({ + postSlug: postAuthors.postSlug, + id: profiles.slug, + name: profiles.name, + profileImage: profiles.profileImage, + }) + .from(postAuthors) + .innerJoin(profiles, eq(profiles.slug, postAuthors.authorSlug)) + .where(inArray(postAuthors.postSlug, slugs)) + : []; + + const authorsByPostSlug = new Map(); + for (const authorRow of authorRows) { + const entries = authorsByPostSlug.get(authorRow.postSlug) ?? []; + entries.push({ + id: authorRow.id, + name: authorRow.name, + profileImageUrl: authorRow.profileImage + ? createImageUrl(authorRow.profileImage) + : undefined, + }); + authorsByPostSlug.set(authorRow.postSlug, entries); + } + + const postsResponse: PostsResponse = rows.map((row) => ({ + slug: row.slug, + title: row.title, + bannerUrl: row.bannerImage + ? createImageUrl(row.bannerImage) + : undefined, + wordCount: row.wordCount, + publishedAt: row.publishedAt + ? row.publishedAt.toISOString() + : undefined, + authors: authorsByPostSlug.get(row.slug) ?? [], + tags: row.tags, + })); + + reply.code(200); + reply.send(postsResponse); + }, + ); +}; + +export default postsRoutes; diff --git a/apps/api/test-utils/setup.ts b/apps/api/test-utils/setup.ts index b70cd35f..a50ec0a0 100644 --- a/apps/api/test-utils/setup.ts +++ b/apps/api/test-utils/setup.ts @@ -21,6 +21,33 @@ vi.mock("@playfulprogramming/redis", () => { vi.mock("@playfulprogramming/db", () => { return { + posts: { + slug: {}, + collectionSlug: {}, + }, + postData: { + slug: {}, + locale: {}, + title: {}, + bannerImage: {}, + wordCount: {}, + publishedAt: {}, + noindex: {}, + }, + postTags: { + postSlug: {}, + tag: {}, + }, + postAuthors: { + postSlug: {}, + authorSlug: {}, + }, + profiles: { + slug: {}, + name: {}, + description: {}, + profileImage: {}, + }, db: { query: { postImages: { @@ -39,6 +66,7 @@ vi.mock("@playfulprogramming/db", () => { findMany: vi.fn(), }, }, + select: vi.fn(), }, }; }); From 91bdf1403f49caea28872dce238cd07dbbd7d0f8 Mon Sep 17 00:00:00 2001 From: Brian Bornino Date: Wed, 8 Jul 2026 08:49:04 -0700 Subject: [PATCH 2/3] fix(api): address CodeRabbit feedback on posts list endpoint (PR #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 --- apps/api/src/routes/content/posts.test.ts | 14 +++++--------- apps/api/src/routes/content/posts.ts | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/api/src/routes/content/posts.test.ts b/apps/api/src/routes/content/posts.test.ts index 73bc492e..cbe2150e 100644 --- a/apps/api/src/routes/content/posts.test.ts +++ b/apps/api/src/routes/content/posts.test.ts @@ -1,7 +1,7 @@ import fastify, { type FastifyInstance } from "fastify"; import postsRoutes from "./posts.ts"; -import { db, postAuthors, postData } from "@playfulprogramming/db"; -import { and, asc, desc, eq, isNotNull } from "drizzle-orm"; +import { db, postAuthors, postData, posts } from "@playfulprogramming/db"; +import { and, asc, desc, eq, isNotNull, sql } from "drizzle-orm"; function mockPostsSelectChain(rows: unknown[]) { const offset = vi.fn().mockResolvedValue(rows); @@ -9,10 +9,7 @@ function mockPostsSelectChain(rows: unknown[]) { const orderBy = vi.fn().mockReturnValue({ limit }); const groupBy = vi.fn().mockReturnValue({ orderBy }); const where = vi.fn().mockReturnValue({ groupBy }); - const leftJoinPostAuthors = vi.fn().mockReturnValue({ where }); - const leftJoinPostTags = vi - .fn() - .mockReturnValue({ leftJoin: leftJoinPostAuthors }); + const leftJoinPostTags = vi.fn().mockReturnValue({ where }); const innerJoinPostData = vi .fn() .mockReturnValue({ leftJoin: leftJoinPostTags }); @@ -22,7 +19,6 @@ function mockPostsSelectChain(rows: unknown[]) { from, innerJoinPostData, leftJoinPostTags, - leftJoinPostAuthors, where, groupBy, orderBy, @@ -249,7 +245,7 @@ describe("Posts Routes Tests", () => { ); }); - test("author filter matches co-authors via the postAuthors join", async () => { + test("author filter adds an EXISTS clause matching co-authors, not just a primary author", async () => { const { postsChain } = mockDbSelect([]); const response = await app.inject({ @@ -263,7 +259,7 @@ describe("Posts Routes Tests", () => { and( isNotNull(postData.publishedAt), eq(postData.noindex, false), - eq(postAuthors.authorSlug, "crutchcorn"), + sql`exists (select 1 from ${postAuthors} where ${postAuthors.postSlug} = ${posts.slug} and ${postAuthors.authorSlug} = ${"crutchcorn"})`, ), ); }); diff --git a/apps/api/src/routes/content/posts.ts b/apps/api/src/routes/content/posts.ts index b6b62f20..a6c5cb25 100644 --- a/apps/api/src/routes/content/posts.ts +++ b/apps/api/src/routes/content/posts.ts @@ -14,8 +14,8 @@ import { PostBaseSchema } from "./postBaseSchema.ts"; const PostsQueryParamsSchema = Type.Object({ locale: Type.String({ default: "en" }), - page: Type.Number({ minimum: 0 }), - limit: Type.Number({ minimum: 1 }), + page: Type.Number({ minimum: 0, default: 0 }), + limit: Type.Number({ minimum: 1, maximum: 100, default: 20 }), sort: Type.Union([Type.Literal("newest"), Type.Literal("oldest")], { default: "newest", }), @@ -79,7 +79,9 @@ const postsRoutes: FastifyPluginAsync = async (fastify) => { eq(postData.noindex, false), ]; if (author) { - conditions.push(eq(postAuthors.authorSlug, author)); + conditions.push( + sql`exists (select 1 from ${postAuthors} where ${postAuthors.postSlug} = ${posts.slug} and ${postAuthors.authorSlug} = ${author})`, + ); } const rows = await db @@ -96,10 +98,17 @@ const postsRoutes: FastifyPluginAsync = async (fastify) => { .from(posts) .innerJoin( postData, - and(eq(postData.slug, posts.slug), eq(postData.locale, locale)), + and( + eq(postData.slug, posts.slug), + eq(postData.locale, locale), + // No documented "current version" convention exists anywhere else in + // the query layer (post.ts doesn't filter by version either); this + // pins to the schema's default empty-string version to avoid + // returning duplicate rows per slug/locale until one is established. + eq(postData.version, ""), + ), ) .leftJoin(postTags, eq(postTags.postSlug, posts.slug)) - .leftJoin(postAuthors, eq(postAuthors.postSlug, posts.slug)) .where(and(...conditions)) .groupBy( posts.slug, From ecfd31bd5f93efdcd57cebe91a029465b8ceee79 Mon Sep 17 00:00:00 2001 From: James Fenn Date: Mon, 13 Jul 2026 21:07:59 -0400 Subject: [PATCH 3/3] fix prettier --- apps/api/test-utils/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/test-utils/setup.ts b/apps/api/test-utils/setup.ts index 5759d258..aac55151 100644 --- a/apps/api/test-utils/setup.ts +++ b/apps/api/test-utils/setup.ts @@ -37,7 +37,7 @@ vi.mock("@playfulprogramming/db", () => { postTags: { postSlug: {}, tag: {}, - }, + }, profiles: { slug: {}, name: {},