From 27a34d47a75fd05a5d30f207ac760de003230111 Mon Sep 17 00:00:00 2001 From: Brian Bornino Date: Tue, 30 Jun 2026 13:12:42 -0700 Subject: [PATCH 1/3] feat(api): add GET /content/post/:slug endpoint Closes #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 --- apps/api/src/createApp.ts | 2 + apps/api/src/routes/content/post.test.ts | 288 +++++++++++++++++++++++ apps/api/src/routes/content/post.ts | 195 +++++++++++++++ apps/api/test-utils/setup.ts | 3 + 4 files changed, 488 insertions(+) create mode 100644 apps/api/src/routes/content/post.test.ts create mode 100644 apps/api/src/routes/content/post.ts diff --git a/apps/api/src/createApp.ts b/apps/api/src/createApp.ts index c219e454..848e0c58 100644 --- a/apps/api/src/createApp.ts +++ b/apps/api/src/createApp.ts @@ -6,6 +6,7 @@ import { healthRoutes } from "./routes/health.ts"; import postImagesRoutes from "./routes/tasks/post-images.ts"; import urlMetadataRoutes from "./routes/tasks/url-metadata.ts"; import collectionsRoutes from "./routes/content/collections.ts"; +import postRoutes from "./routes/content/post.ts"; import fastify from "fastify"; import devRoutes from "./routes/dev/index.ts"; @@ -21,6 +22,7 @@ export const createApp = () => { app.register(postImagesRoutes); app.register(urlMetadataRoutes); app.register(collectionsRoutes); + app.register(postRoutes); if (env.ENVIRONMENT === "development") { app.register(devRoutes); diff --git a/apps/api/src/routes/content/post.test.ts b/apps/api/src/routes/content/post.test.ts new file mode 100644 index 00000000..da7a9acb --- /dev/null +++ b/apps/api/src/routes/content/post.test.ts @@ -0,0 +1,288 @@ +import fastify, { type FastifyInstance } from "fastify"; +import postRoutes from "./post.ts"; +import { db } from "@playfulprogramming/db"; + +describe("Post Routes Tests", () => { + let app: FastifyInstance; + beforeAll(async () => { + app = fastify(); + await app.register(postRoutes); + }); + + afterAll(async () => { + await app.close(); + }); + + describe("/content/post/:slug", () => { + test("returns a post with its authors and a chapter list sorted by collectionOrder", async () => { + vi.mocked(db.query.posts.findFirst).mockResolvedValue({ + slug: "chapter-two", + data: [ + { + title: "Chapter Two", + description: "The second chapter", + bannerImage: "content/banner.png", + socialImage: "content/social.png", + wordCount: 500, + publishedAt: new Date("2024-01-15T00:00:00Z"), + }, + ], + authors: [ + { + slug: "crutchcorn", + name: "Corbin Crutchley", + profileImage: "content/profile.png", + }, + ], + collection: { + slug: "example-collection", + posts: [ + { + slug: "chapter-two", + collectionOrder: 1, + data: [{ title: "Chapter Two" }], + }, + { + slug: "chapter-one", + collectionOrder: 0, + data: [{ title: "Chapter One" }], + }, + ], + }, + } as never); + + const response = await app.inject({ + method: "GET", + url: "/content/post/chapter-two", + query: { locale: "en" }, + }); + + 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", + "chapters": [ + { + "collectionOrder": 0, + "isCurrent": false, + "slug": "chapter-one", + "title": "Chapter One", + }, + { + "collectionOrder": 1, + "isCurrent": true, + "slug": "chapter-two", + "title": "Chapter Two", + }, + ], + "description": "The second chapter", + "publishedAt": "2024-01-15T00:00:00.000Z", + "slug": "chapter-two", + "socialImageUrl": "https://s3_public_url.test/s3_bucket/content/social.png", + "title": "Chapter Two", + "wordCount": 500, + } + `); + }); + + test("returns an empty chapter list when the post is not part of a collection", async () => { + vi.mocked(db.query.posts.findFirst).mockResolvedValue({ + slug: "standalone-post", + data: [ + { + title: "Standalone Post", + description: "A post with no collection", + bannerImage: null, + socialImage: null, + wordCount: 200, + publishedAt: new Date("2024-01-15T00:00:00Z"), + }, + ], + authors: [ + { slug: "crutchcorn", name: "Corbin Crutchley", profileImage: null }, + ], + collection: null, + } as never); + + const response = await app.inject({ + method: "GET", + url: "/content/post/standalone-post", + query: { locale: "en" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchInlineSnapshot(` + { + "authors": [ + { + "id": "crutchcorn", + "name": "Corbin Crutchley", + }, + ], + "chapters": [], + "description": "A post with no collection", + "publishedAt": "2024-01-15T00:00:00.000Z", + "slug": "standalone-post", + "title": "Standalone Post", + "wordCount": 200, + } + `); + }); + + test("returns 404 when the requested post is unpublished for the locale", async () => { + vi.mocked(db.query.posts.findFirst).mockResolvedValue({ + slug: "draft-post", + data: [ + { + title: "Draft Post", + description: "Not yet published", + bannerImage: null, + socialImage: null, + wordCount: 100, + publishedAt: null, + }, + ], + authors: [], + collection: null, + } as never); + + const response = await app.inject({ + method: "GET", + url: "/content/post/draft-post", + query: { locale: "en" }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "Post not found", + } + `); + }); + + test("excludes unpublished sibling chapters from the chapter list", async () => { + vi.mocked(db.query.posts.findFirst).mockResolvedValue({ + slug: "chapter-one", + data: [ + { + title: "Chapter One", + description: "The first chapter", + bannerImage: null, + socialImage: null, + wordCount: 300, + publishedAt: new Date("2024-01-15T00:00:00Z"), + }, + ], + authors: [], + collection: { + slug: "example-collection", + posts: [ + { + slug: "chapter-one", + collectionOrder: 0, + data: [ + { + title: "Chapter One", + publishedAt: new Date("2024-01-15T00:00:00Z"), + }, + ], + }, + { + slug: "chapter-two-draft", + collectionOrder: 1, + data: [{ title: "Chapter Two (Draft)", publishedAt: null }], + }, + { + slug: "chapter-three", + collectionOrder: 2, + data: [ + { + title: "Chapter Three", + publishedAt: new Date("2024-01-20T00:00:00Z"), + }, + ], + }, + ], + }, + } as never); + + const response = await app.inject({ + method: "GET", + url: "/content/post/chapter-one", + query: { locale: "en" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchInlineSnapshot(` + { + "authors": [], + "chapters": [ + { + "collectionOrder": 0, + "isCurrent": true, + "slug": "chapter-one", + "title": "Chapter One", + }, + { + "collectionOrder": 2, + "isCurrent": false, + "slug": "chapter-three", + "title": "Chapter Three", + }, + ], + "description": "The first chapter", + "publishedAt": "2024-01-15T00:00:00.000Z", + "slug": "chapter-one", + "title": "Chapter One", + "wordCount": 300, + } + `); + }); + + test("returns 404 when the post does not exist", async () => { + vi.mocked(db.query.posts.findFirst).mockResolvedValue(undefined); + + const response = await app.inject({ + method: "GET", + url: "/content/post/non-existent-post", + query: { locale: "en" }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "Post not found", + } + `); + }); + + test("returns 404 when the post has no post_data row for the requested locale", async () => { + vi.mocked(db.query.posts.findFirst).mockResolvedValue({ + slug: "spanish-only-post", + data: [], + authors: [], + collection: null, + } as never); + + const response = await app.inject({ + method: "GET", + url: "/content/post/spanish-only-post", + query: { locale: "en" }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json()).toMatchInlineSnapshot(` + { + "error": "Post not found", + } + `); + }); + }); +}); diff --git a/apps/api/src/routes/content/post.ts b/apps/api/src/routes/content/post.ts new file mode 100644 index 00000000..cf471f6d --- /dev/null +++ b/apps/api/src/routes/content/post.ts @@ -0,0 +1,195 @@ +import type { FastifyPluginAsync } from "fastify"; +import { db } from "@playfulprogramming/db"; +import { Type, type Static } from "typebox"; +import { createImageUrl } from "../../utils.ts"; + +const PostParamsSchema = Type.Object({ + slug: Type.String(), +}); + +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()), + }), + ), + chapters: Type.Array( + Type.Object({ + slug: Type.String(), + title: Type.String(), + collectionOrder: Type.Number(), + isCurrent: Type.Boolean(), + }), + ), + }, + { + examples: [ + { + slug: "example-post", + title: "Example Post", + description: "A test post", + bannerUrl: "https://example.test/banner.png", + socialImageUrl: "https://example.test/social.png", + wordCount: 1200, + publishedAt: "2024-01-15T00:00:00.000Z", + authors: [ + { + id: "crutchcorn", + name: "Corbin Crutchley", + profileImageUrl: "https://example.test/profile.jpg", + }, + ], + chapters: [ + { + slug: "example-post", + title: "Example Post", + collectionOrder: 0, + isCurrent: true, + }, + { + slug: "example-post-2", + title: "Example Post 2", + collectionOrder: 1, + isCurrent: false, + }, + ], + }, + ], + }, +); + +type PostResponse = Static; + +const PostErrorResponseSchema = Type.Object({ + error: Type.String(), +}); + +const postRoutes: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Params: Static; + Querystring: Static; + Reply: PostResponse | { error: string }; + }>( + "/content/post/:slug", + { + schema: { + description: + "Fetch a single post, its authors, and its collection chapter list", + params: PostParamsSchema, + querystring: PostQueryParamsSchema, + response: { + 200: { + description: "Successful", + content: { + "application/json": { schema: PostResponseSchema }, + }, + }, + 404: { + description: "Post not found", + content: { + "application/json": { schema: PostErrorResponseSchema }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { slug } = request.params; + const { locale } = request.query; + + 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 }, + }, + }, + }, + }, + }, + }, + }); + + const postData = post?.data[0]; + + if (!post || !postData || postData.publishedAt === null) { + reply.code(404); + reply.send({ error: "Post not found" }); + return; + } + + const chapters = (post.collection?.posts ?? []) + .filter( + (chapter) => chapter.data[0] && chapter.data[0].publishedAt !== null, + ) + .map((chapter) => ({ + slug: chapter.slug, + title: chapter.data[0].title, + collectionOrder: chapter.collectionOrder, + isCurrent: chapter.slug === post.slug, + })) + .sort((a, b) => a.collectionOrder - b.collectionOrder); + + const response: PostResponse = { + slug: post.slug, + title: postData.title, + description: postData.description, + bannerUrl: postData.bannerImage + ? createImageUrl(postData.bannerImage) + : undefined, + socialImageUrl: postData.socialImage + ? createImageUrl(postData.socialImage) + : undefined, + wordCount: postData.wordCount, + publishedAt: postData.publishedAt + ? postData.publishedAt.toISOString() + : undefined, + authors: post.authors.map((author) => ({ + id: author.slug, + name: author.name, + profileImageUrl: author.profileImage + ? createImageUrl(author.profileImage) + : undefined, + })), + chapters, + }; + + reply.code(200); + reply.send(response); + }, + ); +}; + +export default postRoutes; diff --git a/apps/api/test-utils/setup.ts b/apps/api/test-utils/setup.ts index 86a52d72..25f586d9 100644 --- a/apps/api/test-utils/setup.ts +++ b/apps/api/test-utils/setup.ts @@ -32,6 +32,9 @@ vi.mock("@playfulprogramming/db", () => { collections: { findMany: vi.fn(), }, + posts: { + findFirst: vi.fn(), + }, }, }, }; From 9dc39381469e35a2eb47088304013edcc1a2ab3a Mon Sep 17 00:00:00 2001 From: Brian Bornino Date: Thu, 2 Jul 2026 14:46:52 -0700 Subject: [PATCH 2/3] fix(api): address James's PR #174 review feedback Drop collectionOrder and isCurrent from each chapter (the frontend can derive both from array position and slug comparison), and restructure the response to nest collection info under collection: { slug, title, chapters } instead of a flat chapters array. --- apps/api/src/routes/content/post.test.ts | 61 ++++++++++----------- apps/api/src/routes/content/post.ts | 68 ++++++++++++++---------- 2 files changed, 70 insertions(+), 59 deletions(-) diff --git a/apps/api/src/routes/content/post.test.ts b/apps/api/src/routes/content/post.test.ts index da7a9acb..1b0ad95a 100644 --- a/apps/api/src/routes/content/post.test.ts +++ b/apps/api/src/routes/content/post.test.ts @@ -36,6 +36,7 @@ describe("Post Routes Tests", () => { ], collection: { slug: "example-collection", + data: [{ title: "Example Collection" }], posts: [ { slug: "chapter-two", @@ -68,20 +69,20 @@ describe("Post Routes Tests", () => { }, ], "bannerUrl": "https://s3_public_url.test/s3_bucket/content/banner.png", - "chapters": [ - { - "collectionOrder": 0, - "isCurrent": false, - "slug": "chapter-one", - "title": "Chapter One", - }, - { - "collectionOrder": 1, - "isCurrent": true, - "slug": "chapter-two", - "title": "Chapter Two", - }, - ], + "collection": { + "chapters": [ + { + "slug": "chapter-one", + "title": "Chapter One", + }, + { + "slug": "chapter-two", + "title": "Chapter Two", + }, + ], + "slug": "example-collection", + "title": "Example Collection", + }, "description": "The second chapter", "publishedAt": "2024-01-15T00:00:00.000Z", "slug": "chapter-two", @@ -92,7 +93,7 @@ describe("Post Routes Tests", () => { `); }); - test("returns an empty chapter list when the post is not part of a collection", async () => { + test("omits collection when the post is not part of a collection", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "standalone-post", data: [ @@ -126,7 +127,6 @@ describe("Post Routes Tests", () => { "name": "Corbin Crutchley", }, ], - "chapters": [], "description": "A post with no collection", "publishedAt": "2024-01-15T00:00:00.000Z", "slug": "standalone-post", @@ -183,6 +183,7 @@ describe("Post Routes Tests", () => { authors: [], collection: { slug: "example-collection", + data: [{ title: "Example Collection" }], posts: [ { slug: "chapter-one", @@ -223,20 +224,20 @@ describe("Post Routes Tests", () => { expect(response.json()).toMatchInlineSnapshot(` { "authors": [], - "chapters": [ - { - "collectionOrder": 0, - "isCurrent": true, - "slug": "chapter-one", - "title": "Chapter One", - }, - { - "collectionOrder": 2, - "isCurrent": false, - "slug": "chapter-three", - "title": "Chapter Three", - }, - ], + "collection": { + "chapters": [ + { + "slug": "chapter-one", + "title": "Chapter One", + }, + { + "slug": "chapter-three", + "title": "Chapter Three", + }, + ], + "slug": "example-collection", + "title": "Example Collection", + }, "description": "The first chapter", "publishedAt": "2024-01-15T00:00:00.000Z", "slug": "chapter-one", diff --git a/apps/api/src/routes/content/post.ts b/apps/api/src/routes/content/post.ts index cf471f6d..e239bd51 100644 --- a/apps/api/src/routes/content/post.ts +++ b/apps/api/src/routes/content/post.ts @@ -27,12 +27,16 @@ const PostResponseSchema = Type.Object( profileImageUrl: Type.Optional(Type.String()), }), ), - chapters: Type.Array( + collection: Type.Optional( Type.Object({ slug: Type.String(), title: Type.String(), - collectionOrder: Type.Number(), - isCurrent: Type.Boolean(), + chapters: Type.Array( + Type.Object({ + slug: Type.String(), + title: Type.String(), + }), + ), }), ), }, @@ -53,20 +57,14 @@ const PostResponseSchema = Type.Object( profileImageUrl: "https://example.test/profile.jpg", }, ], - chapters: [ - { - slug: "example-post", - title: "Example Post", - collectionOrder: 0, - isCurrent: true, - }, - { - slug: "example-post-2", - title: "Example Post 2", - collectionOrder: 1, - isCurrent: false, - }, - ], + collection: { + slug: "example-collection", + title: "Example Collection", + chapters: [ + { slug: "example-post", title: "Example Post" }, + { slug: "example-post-2", title: "Example Post 2" }, + ], + }, }, ], }, @@ -128,6 +126,10 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { authors: { columns: { slug: true, name: true, profileImage: true } }, collection: { with: { + data: { + columns: { title: true }, + where: { locale }, + }, posts: { columns: { slug: true, collectionOrder: true }, with: { @@ -150,17 +152,25 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { return; } - const chapters = (post.collection?.posts ?? []) - .filter( - (chapter) => chapter.data[0] && chapter.data[0].publishedAt !== null, - ) - .map((chapter) => ({ - slug: chapter.slug, - title: chapter.data[0].title, - collectionOrder: chapter.collectionOrder, - isCurrent: chapter.slug === post.slug, - })) - .sort((a, b) => a.collectionOrder - b.collectionOrder); + const collectionData = post.collection?.data[0]; + + const collection: PostResponse["collection"] = + post.collection && collectionData + ? { + slug: post.collection.slug, + title: collectionData.title, + chapters: post.collection.posts + .filter( + (chapter) => + chapter.data[0] && chapter.data[0].publishedAt !== null, + ) + .sort((a, b) => a.collectionOrder - b.collectionOrder) + .map((chapter) => ({ + slug: chapter.slug, + title: chapter.data[0].title, + })), + } + : undefined; const response: PostResponse = { slug: post.slug, @@ -183,7 +193,7 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { ? createImageUrl(author.profileImage) : undefined, })), - chapters, + collection, }; reply.code(200); From 20d8cc161e18dbd84e529756fcc574c1d6eed279 Mon Sep 17 00:00:00 2001 From: James Fenn Date: Fri, 3 Jul 2026 20:46:01 -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 30aa3c74..b70cd35f 100644 --- a/apps/api/test-utils/setup.ts +++ b/apps/api/test-utils/setup.ts @@ -34,7 +34,7 @@ vi.mock("@playfulprogramming/db", () => { }, posts: { findFirst: vi.fn(), - }, + }, profiles: { findMany: vi.fn(), },