diff --git a/apps/api/src/createApp.ts b/apps/api/src/createApp.ts index a8499d42..0f4dae64 100644 --- a/apps/api/src/createApp.ts +++ b/apps/api/src/createApp.ts @@ -7,6 +7,7 @@ import postImagesRoutes from "./routes/tasks/post-images.ts"; 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 profilesRoutes from "./routes/content/profiles.ts"; import fastify from "fastify"; import devRoutes from "./routes/dev/index.ts"; @@ -24,6 +25,7 @@ export const createApp = () => { app.register(urlMetadataRoutes); app.register(authorsRoutes); app.register(collectionsRoutes); + app.register(postRoutes); app.register(profilesRoutes); if (env.ENVIRONMENT === "development") { 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..1b0ad95a --- /dev/null +++ b/apps/api/src/routes/content/post.test.ts @@ -0,0 +1,289 @@ +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", + data: [{ title: "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", + "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", + "socialImageUrl": "https://s3_public_url.test/s3_bucket/content/social.png", + "title": "Chapter Two", + "wordCount": 500, + } + `); + }); + + test("omits collection 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", + }, + ], + "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", + data: [{ title: "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": [], + "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", + "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..e239bd51 --- /dev/null +++ b/apps/api/src/routes/content/post.ts @@ -0,0 +1,205 @@ +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()), + }), + ), + collection: Type.Optional( + Type.Object({ + slug: Type.String(), + title: Type.String(), + chapters: Type.Array( + Type.Object({ + slug: Type.String(), + title: Type.String(), + }), + ), + }), + ), + }, + { + 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", + }, + ], + collection: { + slug: "example-collection", + title: "Example Collection", + chapters: [ + { slug: "example-post", title: "Example Post" }, + { slug: "example-post-2", title: "Example Post 2" }, + ], + }, + }, + ], + }, +); + +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: { + data: { + columns: { title: true }, + where: { locale }, + }, + 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 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, + 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, + })), + collection, + }; + + 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 db958d64..b70cd35f 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(), + }, profiles: { findMany: vi.fn(), },