diff --git a/apps/api/src/createApp.ts b/apps/api/src/createApp.ts index 83ddf7a3..db21f8d3 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 schemaPostRoutes from "./routes/content/schema-post.ts"; import schemaCollectionRoutes from "./routes/content/schema-collection.ts"; @@ -29,6 +30,7 @@ export const createApp = () => { app.register(authorsRoutes); app.register(collectionsRoutes); app.register(postRoutes); + app.register(postsRoutes); app.register(profilesRoutes); app.register(schemaPostRoutes); app.register(schemaCollectionRoutes); 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..cbe2150e --- /dev/null +++ b/apps/api/src/routes/content/posts.test.ts @@ -0,0 +1,280 @@ +import fastify, { type FastifyInstance } from "fastify"; +import postsRoutes from "./posts.ts"; +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); + 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 leftJoinPostTags = vi.fn().mockReturnValue({ where }); + const innerJoinPostData = vi + .fn() + .mockReturnValue({ leftJoin: leftJoinPostTags }); + const from = vi.fn().mockReturnValue({ innerJoin: innerJoinPostData }); + + return { + from, + innerJoinPostData, + leftJoinPostTags, + 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 adds an EXISTS clause matching co-authors, not just a primary author", 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), + sql`exists (select 1 from ${postAuthors} where ${postAuthors.postSlug} = ${posts.slug} and ${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..a6c5cb25 --- /dev/null +++ b/apps/api/src/routes/content/posts.ts @@ -0,0 +1,176 @@ +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, default: 0 }), + limit: Type.Number({ minimum: 1, maximum: 100, default: 20 }), + 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( + sql`exists (select 1 from ${postAuthors} where ${postAuthors.postSlug} = ${posts.slug} and ${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), + // 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)) + .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 d90e1f44..aac55151 100644 --- a/apps/api/test-utils/setup.ts +++ b/apps/api/test-utils/setup.ts @@ -21,6 +21,23 @@ vi.mock("@playfulprogramming/redis", () => { vi.mock("@playfulprogramming/db", () => { return { + posts: { + slug: {}, + collectionSlug: {}, + }, + postData: { + slug: {}, + locale: {}, + title: {}, + bannerImage: {}, + wordCount: {}, + publishedAt: {}, + noindex: {}, + }, + postTags: { + postSlug: {}, + tag: {}, + }, profiles: { slug: {}, name: {}, @@ -31,11 +48,6 @@ vi.mock("@playfulprogramming/db", () => { postSlug: {}, authorSlug: {}, }, - postData: { - slug: {}, - publishedAt: {}, - noindex: {}, - }, db: { query: { postImages: {