diff --git a/apps/api/scripts/cutover-dry-run.ts b/apps/api/scripts/cutover-dry-run.ts index 5fb6223..66ac9d0 100644 --- a/apps/api/scripts/cutover-dry-run.ts +++ b/apps/api/scripts/cutover-dry-run.ts @@ -193,6 +193,7 @@ const ENDPOINT_TO_SHEET: ReadonlyArray<{ path: string; sheet: string }> = [ { path: '/projects', sheet: 'projects' }, { path: '/project-updates', sheet: 'project-updates' }, { path: '/project-buzz', sheet: 'project-buzz' }, + { path: '/blog', sheet: 'blog-posts' }, ]; export async function runDryRun(opts: DryRunOptions): Promise { diff --git a/apps/api/scripts/import-laddr/importer.ts b/apps/api/scripts/import-laddr/importer.ts index 81e1e2a..7fcbedc 100644 --- a/apps/api/scripts/import-laddr/importer.ts +++ b/apps/api/scripts/import-laddr/importer.ts @@ -37,6 +37,7 @@ import { promisify } from 'node:util'; const exec = promisify(execFile); import { + BlogPostSchema, PersonSchema, ProjectBuzzSchema, ProjectMembershipSchema, @@ -46,6 +47,7 @@ import { TagSchema, } from '@cfp/shared/schemas'; import type { + BlogPost, Person, Project, ProjectBuzz, @@ -58,12 +60,14 @@ import type { import { openPublicStore, type PublicStore } from '../../src/store/public.js'; import { fetchAllPages, + RawBlogPostSchema, RawPersonSchema, RawProjectBuzzSchema, RawProjectSchema, RawProjectUpdateSchema, RawTagSchema, type FetchOptions, + type RawBlogPost, type RawPerson, type RawProject, type RawProjectBuzz, @@ -73,6 +77,7 @@ import { import { newExistingIds, newIdMaps, + translateBlogPost, translateBuzz, translateMembership, translatePerson, @@ -163,6 +168,7 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise( + '/blog', + RawBlogPostSchema, + {}, + fetchOpts, + )) { + const bp = translateBlogPost(row, ctx); + if (bp === null) { + counts['blog-posts']!.skipped++; + continue; + } + const parsedBp = parseOrSkip( + 'blog-posts', + () => BlogPostSchema.parse(bp), + counts, + warnings, + ); + if (parsedBp) { + blogPosts.push(parsedBp); + counts['blog-posts']!.imported++; + } + } + // ------------------------------------------------------------------------- // 2. Dry-run: report and return without touching the repo. // ------------------------------------------------------------------------- @@ -471,6 +502,12 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise(); const tagLegacyByUuid = new Map(); - const simpleSheets = ['people', 'projects', 'tags', 'project-updates', 'project-buzz'] as const; + const simpleSheets = ['people', 'projects', 'tags', 'project-updates', 'project-buzz', 'blog-posts'] as const; for (const sheetName of simpleSheets) { const sheet = store[sheetName] as { query: () => AsyncIterable> }; for await (const record of sheet.query()) { diff --git a/apps/api/scripts/import-laddr/json-fetcher.ts b/apps/api/scripts/import-laddr/json-fetcher.ts index 26da433..dcbb16b 100644 --- a/apps/api/scripts/import-laddr/json-fetcher.ts +++ b/apps/api/scripts/import-laddr/json-fetcher.ts @@ -18,6 +18,7 @@ * embed tag + membership joins. * /project-updates?format=json — flat list, 517 records * /project-buzz?format=json — flat list, 113 records + * /blog?format=json — laddr's BlogRequestHandler list endpoint * * There are no `/project-memberships` or `/tag-assignments` list endpoints; * those come from the project-list `include` parameter (memberships) and @@ -149,6 +150,30 @@ export const RawProjectBuzzSchema = z .passthrough(); export type RawProjectBuzz = z.infer; +/** + * Blog post — laddr's `BlogPost` class. The field set is best-effort + * against laddr's `BlogRequestHandler` template output; unknown fields + * pass through. + * + * ID, Class, Handle (slug), Title, Body, Summary, + * AuthorID, Published (epoch), Modified (epoch), Created (epoch) + */ +export const RawBlogPostSchema = z + .object({ + ID: z.number().int().positive(), + Class: z.string(), + Handle: z.string().nullable().optional(), + Title: z.string().nullable().optional(), + Body: z.string().nullable().optional(), + Summary: z.string().nullable().optional(), + AuthorID: z.number().int().nullable().optional(), + Published: z.number().int().nullable().optional(), + Created: z.number().int().nullable().optional(), + Modified: z.number().int().nullable().optional(), + }) + .passthrough(); +export type RawBlogPost = z.infer; + // --------------------------------------------------------------------------- // Fetcher // --------------------------------------------------------------------------- diff --git a/apps/api/scripts/import-laddr/translators.ts b/apps/api/scripts/import-laddr/translators.ts index 7886c0a..8f20af3 100644 --- a/apps/api/scripts/import-laddr/translators.ts +++ b/apps/api/scripts/import-laddr/translators.ts @@ -26,6 +26,7 @@ import { uuidv7 } from 'uuidv7'; import type { + BlogPost, Person, Project, ProjectBuzz, @@ -36,6 +37,7 @@ import type { } from '@cfp/shared/schemas'; import type { + RawBlogPost, RawMembership, RawPerson, RawProject, @@ -650,6 +652,78 @@ export function translateBuzz( }; } +/** + * Translate a laddr `BlogPost` row into a v1 `BlogPost` record. + * + * Slug source priority: `Handle` (laddr's URL-safe identifier) → + * slugified `Title` → `legacy-`. Bodies are kept verbatim; the + * gitsheets markdown format will normalize them via markdownlint on + * serialize. `AuthorID` resolves via the people-by-legacy map; an + * unresolved author is recorded as a warning but doesn't block the + * post (the runtime treats `authorId === null` as anonymous). + */ +export function translateBlogPost( + row: RawBlogPost, + ctx: TranslateCtx, +): BlogPost | null { + const legacyId = row.ID; + + const handle = nonEmptyStr(row.Handle); + const title = nonEmptyStr(row.Title) ?? `Untitled Post #${legacyId}`; + const slugSource = handle ?? title; + const slug = safeSlug(slugSource, 'blog-posts', 100, false, { + idMaps: ctx.idMaps, + warnings: ctx.warnings, + legacyId, + }); + + const id = idFor(ctx, `blog-posts/${legacyId}`); + + const authorLegacy = typeof row.AuthorID === 'number' ? row.AuthorID : null; + const authorId = + authorLegacy !== null ? ctx.idMaps.personByLegacy.get(authorLegacy) ?? null : null; + if (authorLegacy !== null && authorId === null) { + ctx.warnings.push( + `[blog-posts] legacyId=${legacyId} author=${authorLegacy} unresolved; posting anonymously`, + ); + } + + const createdAt = epochToIsoOr(row.Created, ctx.now); + const updatedAt = epochToIsoOr(row.Modified, createdAt); + // postedAt prefers laddr's Published timestamp; falls back to Created so + // posts that lack an explicit publish date still sort sensibly. + const postedAt = epochToIsoOr(row.Published, createdAt); + // editedAt only surfaces when Modified is meaningfully after Published + // (>60s gap) — otherwise it'd duplicate postedAt for every post. + const editedAt = + typeof row.Modified === 'number' && + typeof row.Published === 'number' && + row.Modified - row.Published > 60 + ? epochToIsoOr(row.Modified, createdAt) + : undefined; + + const body = nonEmptyStr(row.Body) ?? ''; + const summary = nonEmptyStr(row.Summary); + // The schema caps summary at 500 chars; truncate longer laddr summaries + // rather than failing validation on import. + const truncatedSummary = + summary === null ? undefined : summary.length > 500 ? summary.slice(0, 497) + '…' : summary; + + return { + id, + legacyId, + slug, + title, + summary: truncatedSummary, + authorId: authorId ?? undefined, + postedAt, + editedAt, + body, + createdAt, + updatedAt, + }; +} + export interface TagAssignmentResult { readonly assignment: TagAssignment; /** Stable filename component (legacy tag id). */ diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 874c0fb..93e391e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -53,6 +53,7 @@ import { peopleRoutes } from './routes/people.js'; import { tagRoutes } from './routes/tags.js'; import { projectUpdateRoutes } from './routes/projects-updates.js'; import { projectBuzzRoutes } from './routes/projects-buzz.js'; +import { blogPostRoutes } from './routes/blog-posts.js'; import { helpWantedRoutes } from './routes/projects-help-wanted.js'; import { projectMembershipRoutes } from './routes/projects-members.js'; import { previewRoutes } from './routes/preview.js'; @@ -191,6 +192,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { tags: new TagService(state), projectUpdates: new ProjectUpdateService(state), projectBuzz: new ProjectBuzzService(state), + blogPosts: new BlogPostService(state), helpWanted: new HelpWantedService(state, fts), projectsWrite: new ProjectWriteService(state), projectMembershipsWrite: new ProjectMembershipWriteService(state), diff --git a/apps/api/src/routes/blog-posts.ts b/apps/api/src/routes/blog-posts.ts new file mode 100644 index 0000000..eb1018e --- /dev/null +++ b/apps/api/src/routes/blog-posts.ts @@ -0,0 +1,79 @@ +/** + * Blog post routes per specs/api/blog.md. + * + * GET /api/blog-posts → paginated list, newest first, optional ?tag filter + * GET /api/blog-posts/:slug → single post detail + * + * Public reads only — writes happen via PR to the data repo (the + * content-typed gitsheets sheet's on-disk artifact is plain markdown + * with TOML frontmatter). Per-author CMS writes are deferred to #45. + */ +import type { FastifyInstance } from 'fastify'; +import { ok, paginated } from '../lib/response.js'; +import { ApiNotFoundError } from '../lib/errors.js'; + +export async function blogPostRoutes(fastify: FastifyInstance): Promise { + // GET /api/blog-posts + fastify.get( + '/api/blog-posts', + { + schema: { + tags: ['blog-posts'], + summary: 'List blog posts, newest postedAt first', + querystring: { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1 }, + perPage: { type: 'integer', minimum: 1, maximum: 100 }, + since: { type: 'string' }, + tag: { type: 'array', items: { type: 'string' } }, + }, + additionalProperties: false, + }, + }, + }, + async (request) => { + const q = request.query as Record; + const opts = { + page: q['page'] as number | undefined, + perPage: q['perPage'] as number | undefined, + since: q['since'] as string | undefined, + tag: q['tag'] as string[] | undefined, + }; + + const result = fastify.services.blogPosts.list(opts); + + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20)); + + return paginated(result.items, { + page, + perPage, + totalItems: result.totalItems, + totalPages: Math.ceil(result.totalItems / perPage), + }); + }, + ); + + // GET /api/blog-posts/:slug + fastify.get( + '/api/blog-posts/:slug', + { + schema: { + tags: ['blog-posts'], + summary: 'Fetch a single blog post by slug', + params: { + type: 'object', + properties: { slug: { type: 'string' } }, + required: ['slug'], + }, + }, + }, + async (request) => { + const { slug } = request.params as { slug: string }; + const post = fastify.services.blogPosts.findBySlug(slug); + if (!post) throw new ApiNotFoundError(`Blog post '${slug}' not found`); + return ok(post); + }, + ); +} diff --git a/apps/api/src/services/blog-post.ts b/apps/api/src/services/blog-post.ts new file mode 100644 index 0000000..aedceed --- /dev/null +++ b/apps/api/src/services/blog-post.ts @@ -0,0 +1,71 @@ +/** + * BlogPostService — read operations. + * + * Per specs/api/blog.md. Writes happen via PR to the data repo, not the + * runtime — no mutation methods here. + */ +import type { BlogPost } from '@cfp/shared/schemas'; +import type { InMemoryState } from '../store/memory/state.js'; +import { serializeBlogPost, type BlogPostResponse } from './serializers/blog-post.js'; + +export interface BlogPostListOptions { + readonly page?: number; + readonly perPage?: number; + readonly since?: string; + readonly tag?: string[]; +} + +export class BlogPostService { + readonly #state: InMemoryState; + + constructor(state: InMemoryState) { + this.#state = state; + } + + list(opts: BlogPostListOptions): { items: BlogPostResponse[]; totalItems: number } { + // Tag filter — map handles → post ids via TagAssignment.taggableType=blog-post. + let filterPostIds: Set | undefined; + if (opts.tag && opts.tag.length > 0) { + filterPostIds = new Set(); + for (const handle of opts.tag) { + const tagId = this.#state.tagIdByHandle.get(handle); + if (!tagId) continue; + const taIds = this.#state.tagAssignmentsByTag.get(tagId) ?? new Set(); + for (const taId of taIds) { + const ta = this.#state.tagAssignments.get(taId); + if (ta?.taggableType === 'blog_post') filterPostIds.add(ta.taggableId); + } + } + } + + const posts = [...this.#state.blogPosts.values()].filter((p) => { + if (p.deletedAt) return false; + if (opts.since && p.postedAt < opts.since) return false; + if (filterPostIds && !filterPostIds.has(p.id)) return false; + return true; + }); + + posts.sort((a, b) => b.postedAt.localeCompare(a.postedAt)); + + const totalItems = posts.length; + const page = Math.max(1, opts.page ?? 1); + const perPage = Math.min(100, Math.max(1, opts.perPage ?? 20)); + const slice = posts.slice((page - 1) * perPage, page * perPage); + + const items = slice.map((p) => this.#serialize(p)); + return { items, totalItems }; + } + + findBySlug(slug: string): BlogPostResponse | null { + const id = this.#state.blogPostIdBySlug.get(slug); + if (!id) return null; + const post = this.#state.blogPosts.get(id); + if (!post || post.deletedAt) return null; + return this.#serialize(post); + } + + #serialize(post: BlogPost): BlogPostResponse { + const author = post.authorId ? (this.#state.people.get(post.authorId) ?? null) : null; + return serializeBlogPost(post, { author }); + } +} diff --git a/apps/api/src/services/serializers/blog-post.ts b/apps/api/src/services/serializers/blog-post.ts new file mode 100644 index 0000000..5b5747e --- /dev/null +++ b/apps/api/src/services/serializers/blog-post.ts @@ -0,0 +1,44 @@ +/** + * BlogPost serializer. + */ +import type { BlogPost, Person } from '@cfp/shared/schemas'; +import { renderMarkdown, serializePersonAvatar, type PersonAvatar } from './common.js'; + +export interface BlogPostResponse { + readonly id: string; + readonly slug: string; + readonly title: string; + readonly summary: string | null; + readonly author: PersonAvatar | null; + readonly postedAt: string; + readonly editedAt: string | null; + readonly featuredImageKey: string | null; + readonly featuredImageUrl: string | null; + readonly body: string; + readonly bodyHtml: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export function serializeBlogPost( + post: BlogPost, + opts: { author: Person | null }, +): BlogPostResponse { + return { + id: post.id, + slug: post.slug, + title: post.title, + summary: post.summary ?? null, + author: serializePersonAvatar(opts.author), + postedAt: post.postedAt, + editedAt: post.editedAt ?? null, + featuredImageKey: post.featuredImageKey ?? null, + featuredImageUrl: post.featuredImageKey + ? `/api/attachments/${post.featuredImageKey}` + : null, + body: post.body, + bodyHtml: renderMarkdown(post.body).html, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + }; +} diff --git a/apps/api/src/store/memory/loader.ts b/apps/api/src/store/memory/loader.ts index 3c44d09..e8daafa 100644 --- a/apps/api/src/store/memory/loader.ts +++ b/apps/api/src/store/memory/loader.ts @@ -7,6 +7,7 @@ import type { PublicStore } from '../public.js'; import { createEmptyState, + indexBlogPost, indexHelpWantedInterest, indexHelpWantedRole, indexMembership, @@ -31,6 +32,7 @@ export async function loadInMemoryState(publicStore: PublicStore): Promise; projectUpdates: Map; projectBuzz: Map; + blogPosts: Map; helpWantedRoles: Map; helpWantedInterest: Map; @@ -82,6 +84,15 @@ export interface InMemoryState { */ buzzIdBySlug: Map; + /** blogPost.slug → blogPost.id. Per specs/data-model.md#blogpost. */ + blogPostIdBySlug: Map; + /** + * blogPost.legacyId → blogPost.id. Populated only for records carrying a + * laddr legacy ID (the importer sets these). Used for importer idempotence + * and future legacy `/blog/` redirects if needed. + */ + blogPostIdByLegacyId: Map; + /** projectId → Set */ helpWantedByProject: Map>; @@ -119,6 +130,7 @@ export function createEmptyState(): InMemoryState { projectMemberships: new Map(), projectUpdates: new Map(), projectBuzz: new Map(), + blogPosts: new Map(), helpWantedRoles: new Map(), helpWantedInterest: new Map(), @@ -135,6 +147,8 @@ export function createEmptyState(): InMemoryState { buzzByProject: new Map(), buzzByProjectAndSlug: new Map(), buzzIdBySlug: new Map(), + blogPostIdBySlug: new Map(), + blogPostIdByLegacyId: new Map(), helpWantedByProject: new Map(), tagAssignmentsByTaggable: new Map(), tagAssignmentsByTag: new Map(), @@ -250,6 +264,22 @@ export function indexProjectBuzz(state: InMemoryState, buzz: ProjectBuzz): void state.buzzIdBySlug.set(buzz.slug, buzz.id); } +/** Add or replace a blog post and update secondary indices. */ +export function indexBlogPost(state: InMemoryState, post: BlogPost): void { + const old = state.blogPosts.get(post.id); + if (old) { + state.blogPostIdBySlug.delete(old.slug); + if (typeof old.legacyId === 'number') { + state.blogPostIdByLegacyId.delete(old.legacyId); + } + } + state.blogPosts.set(post.id, post); + state.blogPostIdBySlug.set(post.slug, post.id); + if (typeof post.legacyId === 'number') { + state.blogPostIdByLegacyId.set(post.legacyId, post.id); + } +} + /** Add or replace a help-wanted role and update secondary indices. */ export function indexHelpWantedRole(state: InMemoryState, role: HelpWantedRole): void { state.helpWantedRoles.set(role.id, role); diff --git a/apps/api/src/store/public.ts b/apps/api/src/store/public.ts index 89a239d..348caac 100644 --- a/apps/api/src/store/public.ts +++ b/apps/api/src/store/public.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { openRepo, openStore } from 'gitsheets'; import type { Repository, StandardSchemaV1, Store, StoreTx, ValidatorMap } from 'gitsheets'; import { + BlogPostSchema, HelpWantedInterestExpressionSchema, HelpWantedRoleSchema, PersonSchema, @@ -17,6 +18,7 @@ import { TagSchema, } from '@cfp/shared/schemas'; import type { + BlogPost, HelpWantedInterestExpression, HelpWantedRole, Person, @@ -49,6 +51,7 @@ type PublicValidators = { readonly 'project-memberships': StandardSchemaV1; readonly 'project-updates': StandardSchemaV1; readonly 'project-buzz': StandardSchemaV1; + readonly 'blog-posts': StandardSchemaV1; readonly 'help-wanted-roles': StandardSchemaV1; readonly 'help-wanted-interest': StandardSchemaV1; readonly tags: StandardSchemaV1; @@ -98,6 +101,7 @@ export async function openPublicStore( 'project-memberships': asValidator(ProjectMembershipSchema), 'project-updates': asValidator(ProjectUpdateSchema), 'project-buzz': asValidator(ProjectBuzzSchema), + 'blog-posts': asValidator(BlogPostSchema), 'help-wanted-roles': asValidator(HelpWantedRoleSchema), 'help-wanted-interest': asValidator(HelpWantedInterestExpressionSchema), tags: asValidator(TagSchema), diff --git a/apps/api/tests/blog-posts.test.ts b/apps/api/tests/blog-posts.test.ts new file mode 100644 index 0000000..d74c0b1 --- /dev/null +++ b/apps/api/tests/blog-posts.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for GET /api/blog-posts list + detail per specs/api/blog.md. + * + * The blog-posts sheet is content-typed (`[gitsheet.format] type='markdown' + * body='body'`), so on-disk artifacts are Hugo-style markdown files with + * `+++` TOML frontmatter. Tests seed records via `seedRawBlob` writing the + * full `+++\n\n+++\n\n\n` shape gitsheets expects. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../src/app.js'; +import { createFullDataRepo, createPrivateStorageDir } from './helpers/test-full-repo.js'; +import { seedRawBlob } from './helpers/seed-fixtures.js'; + +let dataRepo: { path: string; cleanup: () => Promise }; +let privateStore: { path: string; cleanup: () => Promise }; +let app: FastifyInstance | undefined; + +const UUID_A = '01951a3c-0000-7000-8000-aaaaaaaaaaaa'; +const UUID_B = '01951a3c-0000-7000-8000-bbbbbbbbbbbb'; +const UUID_C = '01951a3c-0000-7000-8000-cccccccccccc'; + +/** Build a markdown record's on-disk bytes per gitsheets' markdown format. */ +function blogRecord(frontmatter: Record, body: string): Buffer { + const lines = Object.entries(frontmatter) + .map(([k, v]) => { + if (typeof v === 'string') return `${k} = ${JSON.stringify(v)}`; + return `${k} = ${JSON.stringify(v)}`; + }) + .join('\n'); + return Buffer.from(`+++\n${lines}\n+++\n\n${body}\n`, 'utf8'); +} + +async function seedBlogPost(opts: { + slug: string; + id: string; + title: string; + body: string; + postedAt: string; + summary?: string | null; + deletedAt?: string | null; +}): Promise { + const fm: Record = { + id: opts.id, + slug: opts.slug, + title: opts.title, + postedAt: opts.postedAt, + createdAt: opts.postedAt, + updatedAt: opts.postedAt, + }; + if (opts.summary !== undefined && opts.summary !== null) fm['summary'] = opts.summary; + if (opts.deletedAt) fm['deletedAt'] = opts.deletedAt; + await seedRawBlob( + dataRepo.path, + `blog-posts/${opts.slug}.md`, + blogRecord(fm, opts.body), + `seed blog-posts/${opts.slug}`, + ); +} + +beforeEach(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); +}); + +afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + await dataRepo.cleanup(); + await privateStore.cleanup(); +}); + +async function bootApp(): Promise { + app = await buildApp({ + serverOptions: { logger: false }, + overrideEnv: { + CFP_DATA_REPO_PATH: dataRepo.path, + STORAGE_BACKEND: 'filesystem', + CFP_PRIVATE_STORAGE_PATH: privateStore.path, + CFP_JWT_SIGNING_KEY: 'test-jwt-signing-key-at-least-32-chars!!', + NODE_ENV: 'test', + }, + }); + return app; +} + +describe('GET /api/blog-posts', () => { + it('returns an empty list when no posts are seeded', async () => { + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts' }); + expect(res.statusCode).toBe(200); + const body = res.json<{ success: true; data: unknown[]; metadata: { totalItems: number } }>(); + expect(body.success).toBe(true); + expect(body.data).toEqual([]); + expect(body.metadata.totalItems).toBe(0); + }); + + it('returns posts sorted by postedAt descending', async () => { + await seedBlogPost({ + slug: 'older', + id: UUID_A, + title: 'Older Post', + body: 'An older body.', + postedAt: '2026-04-01T00:00:00Z', + }); + await seedBlogPost({ + slug: 'newer', + id: UUID_B, + title: 'Newer Post', + body: 'A newer body.', + postedAt: '2026-05-01T00:00:00Z', + }); + + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts' }); + expect(res.statusCode).toBe(200); + const body = res.json<{ data: Array<{ slug: string; title: string; bodyHtml: string }> }>(); + expect(body.data.map((p) => p.slug)).toEqual(['newer', 'older']); + // Markdown is rendered server-side per behaviors/markdown-rendering.md. + expect(body.data[0]!.bodyHtml).toContain('

'); + }); + + it('excludes soft-deleted posts', async () => { + await seedBlogPost({ + slug: 'visible', + id: UUID_A, + title: 'Visible', + body: 'visible body', + postedAt: '2026-04-01T00:00:00Z', + }); + await seedBlogPost({ + slug: 'hidden', + id: UUID_B, + title: 'Hidden', + body: 'hidden body', + postedAt: '2026-05-01T00:00:00Z', + deletedAt: '2026-05-02T00:00:00Z', + }); + + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts' }); + const body = res.json<{ data: Array<{ slug: string }> }>(); + expect(body.data.map((p) => p.slug)).toEqual(['visible']); + }); + + it('paginates correctly', async () => { + await seedBlogPost({ slug: 'a', id: UUID_A, title: 'A', body: 'a', postedAt: '2026-03-01T00:00:00Z' }); + await seedBlogPost({ slug: 'b', id: UUID_B, title: 'B', body: 'b', postedAt: '2026-04-01T00:00:00Z' }); + await seedBlogPost({ slug: 'c', id: UUID_C, title: 'C', body: 'c', postedAt: '2026-05-01T00:00:00Z' }); + + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts?page=2&perPage=1' }); + const body = res.json<{ data: Array<{ slug: string }>; metadata: { totalItems: number; totalPages: number } }>(); + expect(body.data.map((p) => p.slug)).toEqual(['b']); + expect(body.metadata.totalItems).toBe(3); + expect(body.metadata.totalPages).toBe(3); + }); + + it('filters by `since`', async () => { + await seedBlogPost({ slug: 'old', id: UUID_A, title: 'Old', body: 'o', postedAt: '2026-01-01T00:00:00Z' }); + await seedBlogPost({ slug: 'new', id: UUID_B, title: 'New', body: 'n', postedAt: '2026-05-01T00:00:00Z' }); + + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts?since=2026-03-01T00:00:00Z' }); + const body = res.json<{ data: Array<{ slug: string }> }>(); + expect(body.data.map((p) => p.slug)).toEqual(['new']); + }); +}); + +describe('GET /api/blog-posts/:slug', () => { + it('returns a post by slug with bodyHtml rendered', async () => { + await seedBlogPost({ + slug: 'civic-tech-roundup', + id: UUID_A, + title: 'Civic Tech Roundup', + body: '# Heading\n\nSome body content.', + postedAt: '2026-05-01T00:00:00Z', + summary: 'A short blurb.', + }); + + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts/civic-tech-roundup' }); + expect(res.statusCode).toBe(200); + const body = res.json<{ success: true; data: { title: string; summary: string; bodyHtml: string; body: string } }>(); + expect(body.success).toBe(true); + expect(body.data.title).toBe('Civic Tech Roundup'); + expect(body.data.summary).toBe('A short blurb.'); + expect(body.data.body).toContain('# Heading'); + expect(body.data.bodyHtml).toContain('

Some body content.

'); + }); + + it('returns 404 for unknown slug', async () => { + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts/no-such-post' }); + expect(res.statusCode).toBe(404); + }); + + it('returns 404 for soft-deleted post', async () => { + await seedBlogPost({ + slug: 'gone', + id: UUID_A, + title: 'Gone', + body: 'gone body', + postedAt: '2026-05-01T00:00:00Z', + deletedAt: '2026-05-02T00:00:00Z', + }); + + const a = await bootApp(); + const res = await a.inject({ method: 'GET', url: '/api/blog-posts/gone' }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/apps/api/tests/cutover-dry-run.test.ts b/apps/api/tests/cutover-dry-run.test.ts index 81dd15e..9a28493 100644 --- a/apps/api/tests/cutover-dry-run.test.ts +++ b/apps/api/tests/cutover-dry-run.test.ts @@ -148,6 +148,11 @@ function makeMockFetch(): typeof fetch { JSON.stringify(envelope([], 0, 200, 0)), { status: 200 }, ); + case '/blog': + return new Response( + JSON.stringify(envelope([], 0, 200, 0)), + { status: 200 }, + ); default: return new Response(`Not found: ${key}`, { status: 404 }); } diff --git a/apps/api/tests/helpers/test-full-repo.ts b/apps/api/tests/helpers/test-full-repo.ts index 512664a..7b7de4f 100644 --- a/apps/api/tests/helpers/test-full-repo.ts +++ b/apps/api/tests/helpers/test-full-repo.ts @@ -22,6 +22,7 @@ const SHEET_CONFIGS: Record = { 'project-memberships': `[gitsheet]\nroot = 'project-memberships'\npath = '\${{ projectSlug }}/\${{ personSlug }}'\n`, 'project-updates': `[gitsheet]\nroot = 'project-updates'\npath = '\${{ projectSlug }}/\${{ number }}'\n`, 'project-buzz': `[gitsheet]\nroot = 'project-buzz'\npath = '\${{ projectSlug }}/\${{ slug }}'\n`, + 'blog-posts': `[gitsheet]\nroot = 'blog-posts'\npath = '\${{ slug }}'\n\n[gitsheet.format]\ntype = 'markdown'\nbody = 'body'\n`, 'help-wanted-roles': `[gitsheet]\nroot = 'help-wanted-roles'\npath = '\${{ projectSlug }}/\${{ id }}'\n`, 'help-wanted-interest': `[gitsheet]\nroot = 'help-wanted-interest'\npath = '\${{ roleId }}/\${{ personSlug }}'\n`, 'tags': `[gitsheet]\nroot = 'tags'\npath = '\${{ namespace }}/\${{ slug }}'\n`, diff --git a/apps/api/tests/import-laddr.test.ts b/apps/api/tests/import-laddr.test.ts index 4f5a15e..8943a68 100644 --- a/apps/api/tests/import-laddr.test.ts +++ b/apps/api/tests/import-laddr.test.ts @@ -26,11 +26,13 @@ import { newExistingIds, newIdMaps, splitTagHandle, + translateBlogPost, translatePerson, translateProject, translateTag, type TranslateCtx, } from '../scripts/import-laddr/translators.js'; +import type { RawBlogPost } from '../scripts/import-laddr/json-fetcher.js'; const exec = promisify(execFile); @@ -377,6 +379,100 @@ describe('translateProject', () => { }); }); +describe('translateBlogPost', () => { + it('maps the canonical happy-path row', () => { + const c = ctx(); + // Author needs to be resolvable through idMaps. + c.idMaps.personByLegacy.set(12, '01951a3c-0000-7000-8000-000000000012'); + const row: RawBlogPost = { + ID: 5, + Class: 'BlogPost', + Handle: 'civic-tech-roundup-2026', + Title: 'Civic Tech Roundup, May 2026', + Body: '# Heading\n\nA blog body.', + Summary: 'A short blurb.', + AuthorID: 12, + Published: 1746028800, // 2025-04-30 + Created: 1746028800, + Modified: 1746028800, + }; + const bp = translateBlogPost(row, c); + expect(bp).not.toBeNull(); + expect(bp!.slug).toBe('civic-tech-roundup-2026'); + expect(bp!.title).toBe('Civic Tech Roundup, May 2026'); + expect(bp!.body).toBe('# Heading\n\nA blog body.'); + expect(bp!.summary).toBe('A short blurb.'); + expect(bp!.legacyId).toBe(5); + expect(bp!.authorId).toBe('01951a3c-0000-7000-8000-000000000012'); + expect(bp!.postedAt).toBe('2025-04-30T16:00:00.000Z'); + // No edit-window gap → editedAt undefined. + expect(bp!.editedAt).toBeUndefined(); + }); + + it('falls back through Title → legacy- when Handle is missing', () => { + const c = ctx(); + const row: RawBlogPost = { + ID: 9, + Class: 'BlogPost', + Title: 'A Hello Post', + Body: 'body', + Published: 1746028800, + }; + const bp = translateBlogPost(row, c); + expect(bp).not.toBeNull(); + expect(bp!.slug).toBe('a-hello-post'); + }); + + it('warns and posts anonymously when AuthorID does not resolve', () => { + const c = ctx(); + const row: RawBlogPost = { + ID: 11, + Class: 'BlogPost', + Handle: 'orphan', + Title: 'Orphan', + Body: 'orphan', + AuthorID: 999, + Published: 1746028800, + }; + const bp = translateBlogPost(row, c); + expect(bp).not.toBeNull(); + expect(bp!.authorId).toBeUndefined(); + expect(c.warnings.items.some((w) => w.includes('legacyId=11'))).toBe(true); + }); + + it('sets editedAt when Modified is >60s after Published', () => { + const c = ctx(); + const row: RawBlogPost = { + ID: 17, + Class: 'BlogPost', + Handle: 'edited', + Title: 'Edited', + Body: 'edited body', + Published: 1746028800, + Modified: 1746028800 + 3600, // +1 hour + }; + const bp = translateBlogPost(row, c); + expect(bp!.editedAt).toBe('2025-04-30T17:00:00.000Z'); + }); + + it('truncates an over-long summary to 500 chars with an ellipsis', () => { + const c = ctx(); + const overlong = 'x'.repeat(600); + const row: RawBlogPost = { + ID: 23, + Class: 'BlogPost', + Handle: 'long-summary', + Title: 'Long Summary', + Body: 'body', + Summary: overlong, + Published: 1746028800, + }; + const bp = translateBlogPost(row, c); + expect(bp!.summary?.length).toBe(498); // 497 + ellipsis (one codepoint) + expect(bp!.summary?.endsWith('…')).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // End-to-end orchestrator (using the in-memory fetch mock) // --------------------------------------------------------------------------- @@ -414,6 +510,10 @@ async function makeRepo(): Promise<{ path: string; cleanup: () => Promise "root = 'project-updates'\npath = '${{ projectSlug }}/${{ number }}'\n", ], ['project-buzz', "root = 'project-buzz'\npath = '${{ projectSlug }}/${{ slug }}'\n"], + [ + 'blog-posts', + "root = 'blog-posts'\npath = '${{ slug }}'\n\n[gitsheet.format]\ntype = 'markdown'\nbody = 'body'\n", + ], [ 'tag-assignments', "root = 'tag-assignments'\npath = '${{ taggableType }}/${{ taggableId }}/${{ tagId }}'\n", @@ -552,6 +652,30 @@ function mockRoutes(): MockRoutes { ), ], ], + [ + '/blog?format=json&limit=200&offset=0', + [ + envelope( + [ + { + ID: 900, + Class: 'BlogPost', + Handle: 'hello-philly', + Title: 'Hello Philly', + Body: '# Hello\n\nFirst blog post.', + Summary: 'A short hello.', + AuthorID: 10, + Published: 1377126953, + Created: 1377126953, + Modified: 1377126953, + }, + ], + 1, + 200, + 0, + ), + ], + ], ]), }; } diff --git a/apps/api/tests/internal-reload.test.ts b/apps/api/tests/internal-reload.test.ts index b256354..fffd5c5 100644 --- a/apps/api/tests/internal-reload.test.ts +++ b/apps/api/tests/internal-reload.test.ts @@ -41,6 +41,7 @@ const SHEET_CONFIGS: Record = { 'project-memberships': `[gitsheet]\nroot = 'project-memberships'\npath = '\${{ projectSlug }}/\${{ personSlug }}'\n`, 'project-updates': `[gitsheet]\nroot = 'project-updates'\npath = '\${{ projectSlug }}/\${{ number }}'\n`, 'project-buzz': `[gitsheet]\nroot = 'project-buzz'\npath = '\${{ projectSlug }}/\${{ slug }}'\n`, + 'blog-posts': `[gitsheet]\nroot = 'blog-posts'\npath = '\${{ slug }}'\n\n[gitsheet.format]\ntype = 'markdown'\nbody = 'body'\n`, 'help-wanted-roles': `[gitsheet]\nroot = 'help-wanted-roles'\npath = '\${{ projectSlug }}/\${{ id }}'\n`, 'help-wanted-interest': `[gitsheet]\nroot = 'help-wanted-interest'\npath = '\${{ roleId }}/\${{ personSlug }}'\n`, 'tags': `[gitsheet]\nroot = 'tags'\npath = '\${{ namespace }}/\${{ slug }}'\n`, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d19a3e3..91e557d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,6 +16,8 @@ import { Account } from '@/screens/Account'; import { HelpWantedIndex } from '@/screens/HelpWantedIndex'; import { ProjectUpdatesFeed } from '@/screens/ProjectUpdatesFeed'; import { ProjectBuzzFeed } from '@/screens/ProjectBuzzFeed'; +import { BlogIndex } from '@/screens/BlogIndex'; +import { BlogDetail } from '@/screens/BlogDetail'; import { TagsOverview } from '@/screens/TagsOverview'; import { TagsNamespace } from '@/screens/TagsNamespace'; import { TagDetail } from '@/screens/TagDetail'; @@ -49,6 +51,8 @@ const router = createBrowserRouter([ { path: '/members/:slug/edit', element: }, { path: '/project-updates', element: }, { path: '/project-buzz', element: }, + { path: '/blog', element: }, + { path: '/blog/:slug', element: }, { path: '/tags', element: }, { path: '/tags/:namespace', element: }, { path: '/tags/:namespace/:slug', element: }, diff --git a/apps/web/src/components/AppFooter.tsx b/apps/web/src/components/AppFooter.tsx index e176097..620b03f 100644 --- a/apps/web/src/components/AppFooter.tsx +++ b/apps/web/src/components/AppFooter.tsx @@ -124,6 +124,11 @@ export function AppFooter() { Help Wanted +
  • + + Blog + +
  • diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 28dc34e..da26c50 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -268,6 +268,22 @@ export interface ProjectUpdateResponse { readonly updatedAt: string; } +export interface BlogPostResponse { + readonly id: string; + readonly slug: string; + readonly title: string; + readonly summary: string | null; + readonly author: PersonAvatar | null; + readonly postedAt: string; + readonly editedAt: string | null; + readonly featuredImageKey: string | null; + readonly featuredImageUrl: string | null; + readonly body: string; + readonly bodyHtml: string; + readonly createdAt: string; + readonly updatedAt: string; +} + export interface BuzzPermissions { readonly canEdit: boolean; readonly canDelete: boolean; @@ -689,6 +705,12 @@ export const api = { feed: (params: FeedParams = {}): Promise> => request(`/api/project-buzz${buildQuery(params)}`), }, + blogPosts: { + list: (params: FeedParams = {}): Promise> => + request(`/api/blog-posts${buildQuery(params)}`), + bySlug: (slug: string): Promise> => + request(`/api/blog-posts/${encodeURIComponent(slug)}`), + }, auth: { sessions: (): Promise> => request(`/api/auth/sessions`), revokeSession: (jti: string): Promise => diff --git a/apps/web/src/screens/BlogDetail.tsx b/apps/web/src/screens/BlogDetail.tsx new file mode 100644 index 0000000..e7e2a12 --- /dev/null +++ b/apps/web/src/screens/BlogDetail.tsx @@ -0,0 +1,82 @@ +import { Link, useParams } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { MarkdownView } from '@/components/MarkdownView'; +import { NotFound } from '@/pages/NotFound'; +import { api } from '@/lib/api'; + +export function BlogDetail() { + const { slug } = useParams<{ slug: string }>(); + const postQ = useQuery({ + queryKey: ['blog-post', slug], + queryFn: () => (slug ? api.blogPosts.bySlug(slug) : Promise.reject(new Error('no slug'))), + enabled: Boolean(slug), + retry: false, + }); + + if (postQ.isLoading) { + return ( +
    +

    Loading post…

    +
    + ); + } + + if (postQ.isError || !postQ.data) { + return ; + } + + const post = postQ.data.data; + const showEdited = post.editedAt && Math.abs( + new Date(post.editedAt).getTime() - new Date(post.postedAt).getTime(), + ) > 60_000; + + return ( +
    + {post.featuredImageUrl && ( + + )} +
    +

    {post.title}

    +
    + {post.author ? ( + <> + + {post.author.fullName} + + · + + ) : null} + + {showEdited && post.editedAt && ( + <> + · + Edited + + )} +
    +
    + +
    + {post.bodyHtml ? :

    } +
    + +
    + + ← Back to all posts + +
    +
    + ); +} + +function formatPostedAt(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); +} diff --git a/apps/web/src/screens/BlogIndex.tsx b/apps/web/src/screens/BlogIndex.tsx new file mode 100644 index 0000000..36e1253 --- /dev/null +++ b/apps/web/src/screens/BlogIndex.tsx @@ -0,0 +1,162 @@ +import { useCallback } from 'react'; +import { Link, useSearchParams } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { Pagination } from '@/components/Pagination'; +import { TagChip } from '@/components/TagChip'; +import { api } from '@/lib/api'; +import type { BlogPostResponse } from '@/lib/api'; + +export function BlogIndex() { + const [params, setParams] = useSearchParams(); + const page = Math.max(1, parseInt(params.get('page') ?? '1', 10) || 1); + const tags = params.getAll('tag'); + + const listQ = useQuery({ + queryKey: ['blog-posts', { tags, page }], + queryFn: () => + api.blogPosts.list({ + tag: tags.length ? tags : undefined, + page, + perPage: 20, + }), + }); + + const data = listQ.data?.data ?? []; + const meta = listQ.data?.metadata; + const totalPages = meta?.totalPages ?? 1; + const hasFilters = tags.length > 0; + + const updateParams = useCallback( + (mutate: (p: URLSearchParams) => void, resetPage = true) => { + const next = new URLSearchParams(params); + mutate(next); + if (resetPage) next.delete('page'); + setParams(next, { replace: false }); + }, + [params, setParams], + ); + + const handleClearTags = useCallback(() => { + updateParams((p) => p.delete('tag')); + }, [updateParams]); + + return ( +
    +
    +

    Blog

    +

    + Long-form posts from the Code for Philly community. +

    + {hasFilters && ( +
    + Filters: + {tags.map((handle) => { + const [ns, ...slugParts] = handle.split('.'); + const slug = slugParts.join('.'); + return ( + + updateParams((p) => { + const cur = p.getAll('tag').filter((c) => c !== handle); + p.delete('tag'); + for (const c of cur) p.append('tag', c); + }) + } + /> + ); + })} + +
    + )} +
    + + {listQ.isLoading && ( +

    Loading posts…

    + )} + + {listQ.isError && ( +

    Couldn't load blog posts. Try again later.

    + )} + + {!listQ.isLoading && !listQ.isError && data.length === 0 && ( +

    + {hasFilters ? 'No posts match your filter.' : 'No blog posts yet.'} +

    + )} + +
      + {data.map((post) => ( + + ))} +
    + + {totalPages > 1 && ( +
    + + updateParams((p) => { + if (next === 1) p.delete('page'); + else p.set('page', String(next)); + }, false) + } + /> +
    + )} +
    + ); +} + +function BlogIndexCard({ post }: { post: BlogPostResponse }) { + return ( +
  • +
    + {post.featuredImageUrl && ( + + )} +
    +

    + + {post.title} + +

    +
    + {post.author ? ( + <> + + {post.author.fullName} + + · + + ) : null} + +
    + {post.summary && ( +

    {post.summary}

    + )} +
    +
    +
  • + ); +} + +function formatPostedAt(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); +} diff --git a/apps/web/tests/BlogIndex.test.tsx b/apps/web/tests/BlogIndex.test.tsx new file mode 100644 index 0000000..9b0f06b --- /dev/null +++ b/apps/web/tests/BlogIndex.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { renderScreen, mockPaginated } from './test-utils.js'; +import { BlogIndex } from '../src/screens/BlogIndex.js'; +import { AuthProvider } from '../src/hooks/useAuth.js'; + +const SAMPLE_POST = { + id: '01951a3c-0000-7000-8000-aaaaaaaaaaaa', + slug: 'civic-tech-roundup', + title: 'Civic Tech Roundup', + summary: 'A short blurb.', + author: { slug: 'jane', fullName: 'Jane Coder', avatarUrl: null }, + postedAt: '2026-05-10T12:00:00Z', + editedAt: null, + featuredImageKey: null, + featuredImageUrl: null, + body: '# Heading\n\nbody', + bodyHtml: '

    Heading

    body

    ', + createdAt: '2026-05-10T12:00:00Z', + updatedAt: '2026-05-10T12:00:00Z', +}; + +describe('BlogIndex', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve(new Response(null, { status: 404 })); + } + if (input.startsWith('/api/blog-posts')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated([SAMPLE_POST], { totalItems: 1 })), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the page header', async () => { + renderScreen( + + + , + { initialEntries: ['/blog'] }, + ); + await waitFor(() => { + expect(screen.getByRole('heading', { name: /^blog$/i })).toBeInTheDocument(); + }); + }); + + it('renders a post card with title link, author, and summary', async () => { + renderScreen( + + + , + { initialEntries: ['/blog'] }, + ); + await waitFor(() => { + expect(screen.getByRole('link', { name: 'Civic Tech Roundup' })).toBeInTheDocument(); + }); + expect(screen.getByRole('link', { name: 'Jane Coder' })).toBeInTheDocument(); + expect(screen.getByText('A short blurb.')).toBeInTheDocument(); + }); + + it('renders the empty state when no posts are returned', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/auth/me')) { + return Promise.resolve(new Response(null, { status: 404 })); + } + if (input.startsWith('/api/blog-posts')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated([], { totalItems: 0 })), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + + renderScreen( + + + , + { initialEntries: ['/blog'] }, + ); + await waitFor(() => { + expect(screen.getByText(/no blog posts yet/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/shared/src/schemas/blog-post.ts b/packages/shared/src/schemas/blog-post.ts new file mode 100644 index 0000000..52de009 --- /dev/null +++ b/packages/shared/src/schemas/blog-post.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +// `passthrough()` so denormalized path-template fields supplied by write +// services (none today since blog-posts is read-only via the importer + +// PRs, but kept consistent with other sheets) survive validation. Per +// specs/data-model.md#blogpost. +export const BlogPostSchema = z.object({ + id: z.string().uuid(), + legacyId: z.number().int().optional(), + slug: z.string().min(1).max(100), + title: z.string().min(1).max(200), + summary: z.string().max(500).nullable().optional(), + authorId: z.string().uuid().nullable().optional(), + postedAt: z.string().datetime({ offset: true }), + editedAt: z.string().datetime({ offset: true }).nullable().optional(), + featuredImageKey: z.string().nullable().optional(), + deletedAt: z.string().datetime({ offset: true }).nullable().optional(), + body: z.string(), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), +}).passthrough(); + +export type BlogPost = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 2ec1922..398e3d2 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -13,6 +13,9 @@ export type { ProjectUpdate } from './project-update.js'; export { ProjectBuzzSchema } from './project-buzz.js'; export type { ProjectBuzz } from './project-buzz.js'; +export { BlogPostSchema } from './blog-post.js'; +export type { BlogPost } from './blog-post.js'; + export { TagSchema } from './tag.js'; export type { Tag } from './tag.js'; diff --git a/packages/shared/src/schemas/tag-assignment.ts b/packages/shared/src/schemas/tag-assignment.ts index 312360d..230572c 100644 --- a/packages/shared/src/schemas/tag-assignment.ts +++ b/packages/shared/src/schemas/tag-assignment.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; export const TagAssignmentSchema = z.object({ id: z.string().uuid(), tagId: z.string().uuid(), - taggableType: z.enum(['project', 'person', 'help_wanted_role']), + taggableType: z.enum(['project', 'person', 'help_wanted_role', 'blog_post']), taggableId: z.string().uuid(), assignedById: z.string().uuid().nullable().optional(), createdAt: z.string().datetime({ offset: true }), diff --git a/packages/shared/tests/schemas.test.ts b/packages/shared/tests/schemas.test.ts index c422cc4..379e7b4 100644 --- a/packages/shared/tests/schemas.test.ts +++ b/packages/shared/tests/schemas.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + BlogPostSchema, HelpWantedInterestExpressionSchema, HelpWantedRoleSchema, LegacyPasswordCredentialSchema, @@ -188,6 +189,62 @@ describe('ProjectUpdateSchema', () => { }); }); +describe('BlogPostSchema', () => { + const baseBlogPost = { + id: uuid, + slug: 'civic-tech-roundup-2026', + title: 'Civic Tech Roundup, May 2026', + postedAt: now, + body: '# Hello\n\nA blog post body.', + createdAt: now, + updatedAt: now, + }; + + it('accepts a minimal valid post', () => { + const result = BlogPostSchema.safeParse(baseBlogPost); + expect(result.success).toBe(true); + }); + + it('accepts an empty body (drafts arriving from the importer)', () => { + const result = BlogPostSchema.safeParse({ ...baseBlogPost, body: '' }); + expect(result.success).toBe(true); + }); + + it('accepts full optional fields', () => { + const result = BlogPostSchema.safeParse({ + ...baseBlogPost, + legacyId: 42, + summary: 'A short blurb.', + authorId: uuid2, + editedAt: now, + featuredImageKey: 'blog-posts/civic-tech-roundup-2026/cover.jpg', + deletedAt: null, + }); + expect(result.success).toBe(true); + }); + + it('rejects empty title', () => { + const result = BlogPostSchema.safeParse({ ...baseBlogPost, title: '' }); + expect(result.success).toBe(false); + }); + + it('rejects an over-long title', () => { + const result = BlogPostSchema.safeParse({ + ...baseBlogPost, + title: 'x'.repeat(201), + }); + expect(result.success).toBe(false); + }); + + it('rejects an over-long summary', () => { + const result = BlogPostSchema.safeParse({ + ...baseBlogPost, + summary: 'x'.repeat(501), + }); + expect(result.success).toBe(false); + }); +}); + describe('ProjectBuzzSchema', () => { it('accepts a valid buzz item', () => { const result = ProjectBuzzSchema.safeParse({ diff --git a/plans/cutover-blog.md b/plans/cutover-blog.md new file mode 100644 index 0000000..d752588 --- /dev/null +++ b/plans/cutover-blog.md @@ -0,0 +1,311 @@ +--- +status: done +depends: [] +specs: + - specs/api/blog.md + - specs/screens/blog-index.md + - specs/screens/blog-detail.md + - specs/data-model.md + - specs/deferred.md + - specs/behaviors/legacy-id-mapping.md +issues: [84] +pr: 101 +--- + +# Plan: cutover blog — content-typed `blog-posts` sheet + minimum-viable viewer + +## Scope + +Bring laddr's `blog_posts` table back online before cutover so the absence +of historical posts at flip-time doesn't read as a regression. Implements +the [#84](https://github.com/CodeForPhilly/codeforphilly-ng/issues/84) +minimum-viable viewer scope **with the upgrade** to a content-typed +gitsheets sheet (per the broader #45 direction) rather than the +plain-TOML interim scope the original #84 body proposed. + +Why content-typed now: gitsheets v1.3.1 supports +`[gitsheet.format] type='markdown' body='body'` directly (verified +in `node_modules/gitsheets/dist/format/markdown.js`). The on-disk +artifact becomes Hugo-style markdown — `+++` TOML frontmatter + body — +which round-trips through `Sheet.upsert`/`queryAll` transparently and +makes blog content reviewable as plain markdown in PRs to the data +repo. The cost over plain-TOML is one extra format-config line and a +slightly larger boot read (markdown is parsed via the same gitsheets +pipeline). Lazy body loading (`withBody: false`) stays deferred to +[#45](https://github.com/CodeForPhilly/codeforphilly-ng/issues/45) — +not worth the API complication until the post count justifies it. + +Closes [#84](https://github.com/CodeForPhilly/codeforphilly-ng/issues/84). +Reduces (but does not close) [#45](https://github.com/CodeForPhilly/codeforphilly-ng/issues/45) — the content-typed substrate lands here; lazy-loading + full reader experience remain. + +## Implements + +- [api/blog.md](../specs/api/blog.md) — new spec file. +- [screens/blog-index.md](../specs/screens/blog-index.md) — new spec file. +- [screens/blog-detail.md](../specs/screens/blog-detail.md) — new spec file. +- [data-model.md](../specs/data-model.md) — adds `BlogPost` entity. +- [behaviors/legacy-id-mapping.md](../specs/behaviors/legacy-id-mapping.md) — `BlogPost.legacyId` joins the list of migrated sheets. +- [deferred.md](../specs/deferred.md) — updates the "Blog as user-facing CMS" entry to point at this plan + #45. + +## Approach + +### 1. Specs first (write before code) + +Four new + edited spec files. They establish the shape of everything +downstream, so the spec PR-review surface is tight: + +- `specs/api/blog.md` — `GET /api/blog-posts` (list, paginated, + optional `tag` filter) + `GET /api/blog-posts/:slug` (detail). Both + public, no auth. List returns body included (no lazy-load yet) so the + index can render summaries from the body's first paragraph if no + `summary` field is set. Mutations (POST/PATCH/DELETE) are explicitly + out-of-scope — writes happen via PR to the data repo, same as the + importer's snapshot updates. +- `specs/screens/blog-index.md` — `/blog`, paginated reverse-chrono + list of `postedAt`-ordered posts. Each card: title (link), author + avatar+name (link), `postedAt` formatted, summary or first-paragraph + excerpt. Empty state, filtered-empty state for `?tag=` filter. +- `specs/screens/blog-detail.md` — `/blog/:slug`, full post render + (title, author byline, `postedAt`+`editedAt`, body rendered via the + existing server-side markdown pipeline → `bodyHtml`). 404 routing. +- `specs/data-model.md` — `BlogPost` entity inserted alphabetically + near `ProjectUpdate`; secondary indices (`blogPostIdBySlug`, + `blogPostIdByLegacyId`). +- `specs/behaviors/legacy-id-mapping.md` — add `blog-posts` to the + bullet-list of migrated sheets carrying `legacyId`. +- `specs/deferred.md` — update the "Blog (`/blog`) as a user-facing + CMS" entry: superseded by content-typed sheet, pointer to this plan + - #45 for the future lazy-loading/reader work. + +### 2. Schema + +`packages/shared/src/schemas/blog-post.ts`: + +```ts +export const BlogPostSchema = z.object({ + id: z.string().uuid(), + legacyId: z.number().int().optional(), + slug: z.string().min(1).max(100), + title: z.string().min(1).max(200), + summary: z.string().max(500).nullable().optional(), + authorId: z.string().uuid().nullable().optional(), + postedAt: z.string().datetime({ offset: true }), + editedAt: z.string().datetime({ offset: true }).nullable().optional(), + featuredImageKey: z.string().nullable().optional(), + deletedAt: z.string().datetime({ offset: true }).nullable().optional(), + body: z.string(), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), +}).passthrough(); +``` + +Exported through `packages/shared/src/schemas/index.ts`. + +### 3. Data-repo sheet config + +`.gitsheets/blog-posts.toml` (on the data repo's `empty` branch, which +propagates into fixture / legacy-import / published): + +```toml +[gitsheet] +root = 'blog-posts' +path = '${{ slug }}' + +[gitsheet.format] +type = 'markdown' +body = 'body' +title = 'title' +``` + +`title = 'title'` opts into title-from-H1 — the body's first ATX `#` +heading is the authoritative title and gets reflected into the +frontmatter on serialize. Matches what content authors already do. + +This config ships as a separate PR to `codeforphilly-data`. The +codeforphilly-ng PR doesn't depend on it landing first — the boot +loader can gracefully tolerate the sheet being absent (returns empty +results from `queryAll`), and the importer doesn't run in CI. + +### 4. Backend wiring + +- **`apps/api/src/store/public.ts`** — register `blog-posts` in the + `PublicValidators` map (insert next to `project-updates`). +- **`apps/api/src/store/memory/state.ts`** — `blogPosts: Map`, `blogPostIdBySlug: Map`, + `blogPostIdByLegacyId: Map` indices, `indexBlogPost(state, + bp)` helper. +- **`apps/api/src/store/memory/loader.ts`** — add `blog-posts` to the + `Promise.all` queryAll and iterate via `indexBlogPost`. +- **`apps/api/src/services/blog-post.ts`** — `listBlogPosts({ page, + perPage, tag? })`, `findBlogPostBySlug(slug)`. Filters out + `deletedAt != null` and sorts by `postedAt` desc. +- **`apps/api/src/services/serializers/blog-post.ts`** — + `serializeBlogPost(bp, { state, markdownService })`: resolves + `authorId` → `PersonAvatar` (null-safe), renders `body` → + `bodyHtml` via `fastify.markdown.render`, returns the API shape. +- **`apps/api/src/routes/blog-posts.ts`** — two endpoints matching the + spec. +- **`apps/api/src/app.ts`** — register `blogPostsRoutes` after + `chatRoutes`. + +### 5. Importer translator + +`apps/api/scripts/import-laddr/`: + +- **`json-fetcher.ts`** — `RawBlogPost` type matching laddr's + `?format=json` shape (fields: `ID`, `Created`, `Modified`, + `Published`, `Title`, `Slug`, `Body`, `Summary`, `AuthorID`, + `Class`). +- **`translators.ts`** — `translateBlogPost(raw, ctx)`: maps fields, + resolves `AuthorID` via `idMaps.personByLegacy`, mints a fresh + UUIDv7 (or carries existing on re-run), normalizes timestamps + (laddr's epoch-seconds → ISO), slugify-with-dedupe falls back when + slug is missing. +- **`importer.ts`** — fetch + translate + commit in the same pattern + as `project-updates`. Skips on missing `AuthorID` only when the + laddr-author had no corresponding `People` row (rare; today's people + table has them all). + +### 6. SPA + +- **`apps/web/src/screens/BlogIndex.tsx`** — paginated list at + `/blog`. Lazy-loaded route. Loads via existing `apiFetch` helper. +- **`apps/web/src/screens/BlogDetail.tsx`** — single-post render at + `/blog/:slug`. 404 → catch-all NotFound screen. +- **Router**: add the two routes to `apps/web/src/main.tsx`. + +### 7. Tests + +- **Schema** — `packages/shared/tests/schemas/blog-post.test.ts`: + required-fields, body-empty-OK, title-too-long rejects. +- **Importer translator** — `apps/api/tests/import-laddr.test.ts`: + one new case round-tripping a `RawBlogPost` fixture into a + `BlogPost`, including author-resolution. +- **Routes** — `apps/api/tests/blog-posts.test.ts`: list-empty, + list-with-records (seeded via `seedRawBlob` with a real + `+++`-frontmatter markdown file), detail by slug, 404, deletedAt + filter. +- **SPA** — `apps/web/tests/BlogIndex.test.tsx`, + `BlogDetail.test.tsx`: render with fixtures, click-through to detail, + 404 path. + +## Validation + +- [x] All 6 spec files written + reviewed. +- [x] `@cfp/shared` exports `BlogPost` + `BlogPostSchema`. +- [x] Sheet config PR opened against `codeforphilly-data:empty` ([codeforphilly-data#1](https://github.com/CodeForPhilly/codeforphilly-data/pull/1)). +- [x] Backend boot loads `blog-posts` without erroring even when the + sheet is absent (gracefully empty — `queryAll` returns `[]`). +- [x] `GET /api/blog-posts` returns paginated results matching spec + envelope (8 route tests pass). +- [x] `GET /api/blog-posts/:slug` returns 200 with `bodyHtml` + populated + 404 on unknown slug + 404 on soft-deleted. +- [x] `/blog` + `/blog/:slug` render in the SPA (3 BlogIndex tests + pass; BlogDetail relies on shared MarkdownView). +- [x] Importer translator round-trips a fixture row into a valid + `BlogPost` (5 new translator cases pass) and the orchestrator + end-to-end mock includes `/blog`. +- [x] `npm run type-check && npm run lint && npm test` clean. + +## Risks / unknowns + +- **`title = 'title'` body→frontmatter enforcement.** The markdown + format requires the body's first ATX `# H1` to equal the + frontmatter's `title`. Laddr posts may have bodies that don't lead + with an H1, or whose H1 disagrees with the stored title. The + translator needs to either (a) prepend an H1 to bodies that lack one + (using `Title`), or (b) skip the `title` config opt-in and store + title as a normal frontmatter field. Going with (b) — safer for + legacy content; the auto-extraction is a v2 nicety. +- **Body bytes on every list query.** Without `withBody: false` (the + lazy-load feature deferred to #45), `GET /api/blog-posts` reads full + bodies for every record on every request. With laddr having ~few-dozen + posts, this is fine. If counts ever grow past ~100, revisit. +- **Sheet config arrives before app deploy.** If the data repo gets + the `blog-posts.toml` first and the running pod is on an older image + that doesn't know how to validate it, gitsheets will throw at boot. + Mitigation: ship the schema-aware app image **before** merging the + data-repo PR. Or: don't include schema validation in the sheet + config (rely on the app's Zod validator instead). Going with the + latter — simpler and matches the existing sheet configs. + +## Notes + +Five commits across two repos (plus the data-repo PR): + + codeforphilly-ng: + chore(plans): open cutover-blog (in-progress) + docs(specs): blog-posts entity + /blog screens + /api/blog-posts + feat(shared): BlogPost Zod schema + feat(api): GET /api/blog-posts list + detail + feat(importer): translate + import laddr blog_posts + feat(web): /blog index + detail screens + + codeforphilly-data: + feat(gitsheets): add blog-posts content-typed sheet (PR #1) + +Surprises: + +- **The `title = 'title'` body-from-H1 opt-in was tempting but + fragile for legacy content.** I sketched it in the plan and then + backed it out before writing the code: laddr posts can't be assumed + to start with an `# H1` heading whose text exactly equals the stored + Title, and the gitsheets markdown format throws hard on mismatch. + Better to leave title in TOML frontmatter and let H1-extraction + become a v2 nicety once content authors are operating against the + sheet directly. +- **TagAssignment.taggableType needed `'blog_post'`.** The blog-index + spec calls out `?tag=` filtering, but the existing TagAssignment + enum only knew `'project' | 'person' | 'help_wanted_role'`. Adding + the value was a one-line schema change; without it the filter + loop in BlogPostService.list would have been dead code. snake_case + matches the existing convention (`help_wanted_role`). +- **`reload.ts` is missing some pre-existing indices.** While + threading `blogPostIdBySlug` + `blogPostIdByLegacyId` through + `swapInPlace`, I noticed the existing function never copies + `projectIdByLegacyId`, `buzzIdBySlug`, or `slugHistory`. So + hot-reload would have left those indices stale on the live state. + Out of scope here — captured below as a follow-up. +- **Importer pre-pass needs every sheet that mints UUIDs to be in + `simpleSheets`.** Forgot this on the first pass and the + "is idempotent" orchestrator test caught it — the second run was + minting fresh UUIDs for the same blog posts, so every re-run + produced a phantom commit. One-line fix. +- **No `withBody: false` yet.** The plan explicitly defers lazy body + loading to #45. At ~few-dozen posts this is fine; the API will + fetch all bodies on every list request. The boot loader also reads + them all into memory — fine at this scale but worth re-measuring + once we're at >100 posts. + +## Follow-ups + +- **Re-run the laddr importer + merge to `published`** after both PRs + land. Sequence: (1) merge codeforphilly-ng#101 + redeploy sandbox + pod, (2) merge codeforphilly-data#1 to `empty` and let it propagate, + (3) `npm run import-laddr` against the upstream laddr instance to + populate `legacy-import`, (4) merge `legacy-import` → `published` + → the hot-reload webhook surfaces the new blog content. *Deferred + to plan* — sequence runs at sandbox-redeploy time. +- **`reload.ts` missing-indices audit.** `swapInPlace` doesn't + reassign `projectIdByLegacyId`, `buzzIdBySlug`, or `slugHistory`, + so hot-reload leaves them stale relative to the rest of the in- + memory state. Likely a pre-existing bug from when those indices + were added. *Tracked as* — needs its own small issue + plan. +- **Lazy body loading + reader experience** — `withBody: false` on + list reads, prev/next nav, related posts. *Tracked as* — [#45](https://github.com/CodeForPhilly/codeforphilly-ng/issues/45). +- **Blog tagging UI** — the API supports `?tag=` filtering and the + schema allows `TagAssignment.taggableType = 'blog_post'`, but + there's no UI today to apply tags to blog posts (writes are PR-only). + *None* — content authors set tags directly in the frontmatter via + the PR-to-data-repo flow. +- **Featured image upload UI** — `featuredImageKey` is plumbed + through the schema + serializer, but uploading one requires a CMS + surface that doesn't exist (blog writes are PR-only). Content + authors can drop a JPEG into the data repo at + `blog-posts//cover.jpg` and reference the key in the + frontmatter. *None* — explicit non-goal for the cutover scope. +- **Top-nav Blog link** — added only to the footer. Adding to the + top navigation is a design decision worth deferring until there's + a critical mass of posts that justify the visual real estate. + *None* — footer link satisfies the discoverability requirement + from the spec. diff --git a/specs/api/blog.md b/specs/api/blog.md new file mode 100644 index 0000000..d1f2568 --- /dev/null +++ b/specs/api/blog.md @@ -0,0 +1,67 @@ +# API: Blog Posts + +Long-form posts authored by staff. Was laddr's `BlogPost`. See [data-model.md](../data-model.md#blogpost) for the entity shape. + +Public reads only — writes happen via PR to the data repo (the content-typed gitsheets sheet's on-disk artifact is plain markdown with TOML frontmatter, so a PR is the editor). Per-author CMS writes are deferred to [#45](https://github.com/CodeForPhilly/codeforphilly-ng/issues/45). + +## Endpoints + +| Method | Path | Auth | Summary | +| ------ | ---- | ---- | ------- | +| `GET` | `/api/blog-posts` | public | Paginated list of posts, newest `postedAt` first. | +| `GET` | `/api/blog-posts/:slug` | public | Fetch a single post by slug. | + +## GET /api/blog-posts + +### Query + +| Param | Type | Notes | +| ----- | ---- | ----- | +| `page`, `perPage` | int | Default `perPage = 20`. | +| `tag` | string (repeatable) | Filter to posts carrying this tag (namespace.slug handle). | +| `since` | iso8601 | If present, only posts with `postedAt >= since`. | + +### Response — 200 + +```json +{ + "success": true, + "data": [BlogPost, ...], + "metadata": { "page": 1, "perPage": 20, "totalPages": 3, "totalItems": 47 } +} +``` + +Soft-deleted records (`deletedAt != null`) are excluded. + +## GET /api/blog-posts/:slug + +### Response — 200 + +```json +{ "success": true, "data": BlogPost } +``` + +### Response — 404 + +Standard 404 envelope (per [conventions.md](conventions.md)). Slug-history redirects per [behaviors/slug-handles.md](../behaviors/slug-handles.md) apply once the slug-history redirect handler ships. + +## BlogPost shape + +```json +{ + "id": "", + "slug": "civic-tech-roundup-2026", + "title": "Civic Tech Roundup, May 2026", + "summary": "A short markdown blurb (max 500 chars), or null.", + "author": PersonAvatar | null, // null when authorId is absent or person was deleted + "postedAt": "2026-05-15T18:00:00Z", + "editedAt": "2026-05-16T09:30:00Z", // null when unedited + "featuredImageKey": "blog-posts/civic-tech-roundup-2026/cover.jpg", // or null + "body": "Markdown source", + "bodyHtml": "

    ...

    ", // sanitized HTML, server-rendered + "createdAt": "...", + "updatedAt": "..." +} +``` + +`legacyId` is not exposed in the API response — clients don't need it, and surfacing it would invite churn when migrating off laddr. diff --git a/specs/behaviors/legacy-id-mapping.md b/specs/behaviors/legacy-id-mapping.md index ffc6ad4..2bf68bb 100644 --- a/specs/behaviors/legacy-id-mapping.md +++ b/specs/behaviors/legacy-id-mapping.md @@ -6,7 +6,7 @@ The rewrite migrates records from the live laddr site at `codeforphilly.org` int ## Applies To -- [data-model.md](../data-model.md) — `legacyId` field on `people`, `projects`, `project-updates`, `project-buzz`, `tags` (the migrated sheets where laddr's auto-increment IDs were ever referenced externally; `project-memberships` is *not* in this list — laddr's `project_members.ID` never escaped to URLs) +- [data-model.md](../data-model.md) — `legacyId` field on `people`, `projects`, `project-updates`, `project-buzz`, `tags`, `blog-posts` (the migrated sheets where laddr's auto-increment IDs were ever referenced externally; `project-memberships` is *not* in this list — laddr's `project_members.ID` never escaped to URLs) - The re-runnable importer (`apps/api/scripts/import-laddr.ts` — implementation, not spec) which pulls the public dataset via laddr's `?format=json` endpoints - The web layer's legacy-URL redirect handler (described below) - [behaviors/storage.md](storage.md) — the import lands as snapshot commits on a `legacy-import` branch, which the operator merges into `main` to integrate updates diff --git a/specs/data-model.md b/specs/data-model.md index 814367b..b525110 100644 --- a/specs/data-model.md +++ b/specs/data-model.md @@ -28,7 +28,9 @@ Person ──*── ProjectMembership ──*── Project HelpWantedRole (one-to-many) HelpWantedInterestExpression (one-to-many) -Tag ──── TagAssignment ──── (Project | Person | HelpWantedRole) +BlogPost ──── authored-by ──── Person (0:1; staff-authored long-form posts) + +Tag ──── TagAssignment ──── (Project | Person | HelpWantedRole | BlogPost) polymorphic via taggableType + taggableId Person ── has ── Revocation (0:many; revoked JWT IDs) @@ -278,6 +280,39 @@ Markdown updates posted by project members. Was laddr's `project_updates`. No ve **Uniqueness:** `(projectId, number)`. +## BlogPost + +Staff-authored long-form posts at `/blog`. Was laddr's `blog_posts`. Stored as a **content-typed** gitsheets sheet: on-disk artifacts are Hugo-style markdown (`+++` TOML frontmatter + body), one `.md` file per slug. Writes happen via PR to the data repo (not a runtime CMS). See [api/blog.md](api/blog.md), [screens/blog-index.md](screens/blog-index.md), [screens/blog-detail.md](screens/blog-detail.md). + +**Sheet:** `blog-posts` +**Path template:** `blog-posts/${slug}.md` +**Format:** `markdown` (gitsheets `[gitsheet.format]` with `type = 'markdown'`, `body = 'body'`) + +| Field | Type | Notes | +|-------|------|-------| +| id | uuid | | +| legacyId | int nullable | laddr `blog_posts.ID`. Per [behaviors/legacy-id-mapping.md](behaviors/legacy-id-mapping.md). | +| slug | string | unique. URL: `/blog/`. Used as the filename (`.md`). | +| title | string | display title; 1-200 chars. | +| summary | string nullable | short markdown blurb (≤500 chars) for the index card. | +| authorId | uuid nullable | references `people.id`; null if the author was deleted or never set. | +| postedAt | iso8601 | publish timestamp; primary sort key for the index. | +| editedAt | iso8601 nullable | last meaningful edit; surfaces as "Edited " on the detail screen when it differs from `postedAt`. | +| featuredImageKey | string nullable | gitsheets attachment key (e.g., `blog-posts//cover.jpg`). Served via `GET /api/attachments/:key`. | +| deletedAt | iso8601 nullable | soft-delete; excluded from API responses. | +| body | string | markdown body — the **content** of the file, separated from frontmatter by `+++` per the gitsheets content-typed convention. | +| createdAt | iso8601 | | +| updatedAt | iso8601 | | + +**Secondary in-memory indices:** + +- `blogPostIdBySlug: Map` — slug → id for route resolution +- `blogPostIdByLegacyId: Map` — for importer idempotence and (future) legacy URL redirects + +**Uniqueness:** `slug` (global). `legacyId` is unique-where-present. + +**Lazy body loading is deferred to [#45](https://github.com/CodeForPhilly/codeforphilly-ng/issues/45)** — initial implementation loads full bodies on every list query. Acceptable at current scale (<100 posts). + ## ProjectBuzz External media / press / "buzz" about a project. Was laddr's `project_buzz`. @@ -444,12 +479,13 @@ See [behaviors/storage.md](behaviors/storage.md#commits-are-the-audit-log) for t | ProjectUpdate | Person | many-to-one (author) | `ProjectUpdate.authorId` | | ProjectBuzz | Project | many-to-one | `ProjectBuzz.projectId` | | ProjectBuzz | Person | many-to-one (postedBy) | `ProjectBuzz.postedById` | +| BlogPost | Person | many-to-one (author) | `BlogPost.authorId` | | HelpWantedRole | Project | many-to-one | `HelpWantedRole.projectId` | | HelpWantedRole | Person | many-to-one (postedBy / filledBy) | `HelpWantedRole.postedById`, `filledById` | | HelpWantedInterestExpression | HelpWantedRole | many-to-one | `roleId` | | HelpWantedInterestExpression | Person | many-to-one | `personId` | | TagAssignment | Tag | many-to-one | `tagId` | -| TagAssignment | Project \| Person \| HelpWantedRole | polymorphic | `taggableType + taggableId` | +| TagAssignment | Project \| Person \| HelpWantedRole \| BlogPost | polymorphic | `taggableType + taggableId` | Cascading deletes are not enforced by gitsheets; the API's mutation services delete dependent records as part of the same write-and-commit operation (see [behaviors/storage.md](behaviors/storage.md) for atomicity). For project delete this means: in one mutation, write the project's tombstone (`deletedAt`) and (for cascade-on-hard-delete) the dependent project-memberships, project-updates, project-buzz, help-wanted-roles, and tag-assignments are removed. @@ -468,6 +504,10 @@ Cascading deletes are not enforced by gitsheets; the API's mutation services del | `project_updates.Number` | `ProjectUpdate.number` | | `project_buzz.Headline` / `URL` / `Published` / `Summary` / `ImageID` | `ProjectBuzz.headline` / `url` / `publishedAt` / `summary` / `imageKey` | | `tags.Handle` (e.g., `topic.transit`) | `tags.namespace = 'topic'`, `tags.slug = 'transit'` | +| `blog_posts.ID` | `blog-posts` record's `id` (uuid) + `legacyId` (int) | +| `blog_posts.Handle` | `BlogPost.slug` | +| `blog_posts.Title` / `Summary` / `Body` | `BlogPost.title` / `summary` / `body` | +| `blog_posts.AuthorID` / `Published` / `Modified` | `BlogPost.authorId` / `postedAt` / `editedAt` | | `tag_items.ContextClass` / `ContextID` | `tag-assignments.taggableType` / `taggableId` | | `Emergence\People\Person.Username` | `Person.slug` (public) — also seeds the immutable `slackSamlNameId` for Slack SSO stability | | `Emergence\People\Person.Email` | **`PrivateProfile.email`** in the private store (not in the public gitsheets repo) | diff --git a/specs/deferred.md b/specs/deferred.md index d123d25..9e16bfa 100644 --- a/specs/deferred.md +++ b/specs/deferred.md @@ -128,8 +128,9 @@ When a deferred item is promoted, move it from this file into the relevant spec, ### Blog (`/blog`) as a user-facing CMS - **What:** Long-form posts via Emergence CMS's `BlogPost` class — a database-backed editor inside the site, available to a user role. -- **Replaced by:** **Staff-authored markdown files in the code repo** at `apps/web/src/content/blog/.md`. New posts ship via a PR; the web layer renders them at `/blog/` and a `/blog` index page. Same convention as the static `/pages/*` content. -- **Why:** Post velocity has been near-zero for years; a database-backed CMS with user logins is overkill. Markdown-in-repo gives version control, PR review, no auth surface, and lets the same toolchain handle every long-form text on the site. +- **Replaced by:** A **content-typed gitsheets sheet** (`.gitsheets/blog-posts.toml` with `[gitsheet.format] type = 'markdown' body = 'body'`) — on-disk artifacts are Hugo-style markdown files (`+++` TOML frontmatter + body), one per slug, served via `GET /api/blog-posts` + the existing in-memory state machinery. Writes happen via PR to the data repo. See [api/blog.md](api/blog.md), [screens/blog-index.md](screens/blog-index.md), [screens/blog-detail.md](screens/blog-detail.md), [data-model.md → BlogPost](data-model.md#blogpost). +- **Why:** Post velocity has been near-zero for years; a database-backed CMS with user logins is overkill. Markdown bodies in a content-typed sheet keep the PR-review ergonomics of files-in-code-repo while sitting on the same runtime + import pipeline as the rest of the data model. Authors get attribution via `authorId`, posts ride the data snapshot, and the API serves through the existing in-memory state with no Vite-bundle bloat for the index. The original "files in `apps/web/src/content/blog/`" replacement was drafted before gitsheets v1.2 made content-typed records viable; that approach is superseded by this one. +- **Status:** Initial implementation landed via [#84](https://github.com/CodeForPhilly/codeforphilly-ng/issues/84) — full bodies loaded at boot. Lazy body loading (`queryAll({ withBody: false })`) and the richer reader experience are tracked in [#45](https://github.com/CodeForPhilly/codeforphilly-ng/issues/45). ### Email/password authentication diff --git a/specs/screens/blog-detail.md b/specs/screens/blog-detail.md new file mode 100644 index 0000000..586b6ff --- /dev/null +++ b/specs/screens/blog-detail.md @@ -0,0 +1,57 @@ +# Screen: Blog Detail + +## Route + +`/blog/:slug` — public. A single blog post's full content. + +## Data Requirements + +- `GET /api/blog-posts/:slug` returning a `BlogPost` (with `bodyHtml`). +- 404 → catch-all NotFound screen. Slug-history redirects (per [behaviors/slug-handles.md](../behaviors/slug-handles.md)) take precedence over the 404 path once the redirect handler ships. + +## Display Rules + +### Header + +- **Featured image** (if `featuredImageKey` set) full-width above the title. +- H1: the post's `title`. +- Byline directly below the title: + - Author avatar (when non-null) + name (linked to `/members/:slug`) + - `postedAt` formatted (e.g., "May 15, 2026") + - If `editedAt` is set and differs from `postedAt` by more than a minute, show "Edited " subtly to the right. + +### Body + +- Render `bodyHtml` (server-rendered, sanitized) inside a typographic prose container — same styling as project detail's description region. +- External links open in a new tab per [behaviors/markdown-rendering.md](../behaviors/markdown-rendering.md) (when that landing transform ships). +- `@mention` of a member links to their profile per the same spec. + +### Footer + +- Tags carried on the post, rendered as clickable chips linking to `/blog?tag=`. +- "Back to all posts" link → `/blog`. + +### Empty / edge + +- A post whose `body` is empty renders just the header and an em-dash placeholder in the body region — never blank. + +## Actions + +| Action | Effect | +| ------ | ------ | +| Click author | Navigate to `/members/:slug`. | +| Click featured image | No action (no lightbox in v1). | +| Click tag chip | Navigate to `/blog?tag=`. | +| Click "Back to all posts" | Navigate to `/blog`. | + +No edit / delete affordances — writes are via PR to the data repo. + +## Navigation + +**To here:** `/blog` index, person profile (recent posts by this author — future), tag pages. + +**From here:** Person profile, tag pages, `/blog` index. + +## Authorization + +Public. Soft-deleted posts return 404 from the API. diff --git a/specs/screens/blog-index.md b/specs/screens/blog-index.md new file mode 100644 index 0000000..fb061fa --- /dev/null +++ b/specs/screens/blog-index.md @@ -0,0 +1,66 @@ +# Screen: Blog Index + +## Route + +`/blog` — public. Reverse-chronological list of all (non-deleted) `BlogPost` records. + +## Data Requirements + +- `GET /api/blog-posts` with URL query params. +- Markdown is rendered server-side per [behaviors/markdown-rendering.md](../behaviors/markdown-rendering.md) — the API returns `bodyHtml` and `summary` (markdown) already; the SPA does not run a markdown library on user content. + +### URL state + +| URL param | Type | Meaning | +| --------- | ---- | ------- | +| `page`, `perPage` | int | `perPage` default 20. | +| `tag` | string (repeatable) | Filter to posts carrying this tag handle. | +| `since` | iso8601 | Only posts `postedAt >= since`. | + +## Display Rules + +### Header + +- H1 "Blog" +- Subhead: "Long-form posts from the Code for Philly community." +- If `?tag=` is active, show the active tag chip(s) with a "Clear filter" button. + +### List + +- Post cards in reverse chronological order by `postedAt`, full-width. +- Each card displays: + - **Featured image** (left, optional) when `featuredImageKey` is set — served via `GET /api/attachments/:key` (per [api/people.md](../api/people.md)). + - **Title** (linked to `/blog/:slug`), as an H2. + - **Byline**: author avatar + name (linked to person profile if author is non-null) + `postedAt` formatted (e.g., "May 15, 2026"). + - **Summary**: the post's `summary` field rendered as text; if absent, the first paragraph of `bodyHtml` truncated to ~280 chars. +- Pagination at the bottom (prev / numbered pages / next). + +### Empty state + +"No blog posts yet." — neutral copy; the importer hasn't run, or all posts are soft-deleted. + +### Filtered empty state + +"No posts match your filter. [Clear filter]" + +## Actions + +| Action | Effect | +| ------ | ------ | +| Click post title | Navigate to `/blog/:slug`. | +| Click author | Navigate to `/members/:slug`. | +| Click tag chip | Navigate to `/blog?tag=`. | +| Clear filter | Remove `tag` from URL. | +| Click page link | Update `page` query param. | + +No mutations on this screen — writes happen via PR to the data repo. + +## Navigation + +**To here:** Footer link "Blog" — visible site-wide. Home page may also feature recent posts (out of scope here; future enhancement). + +**From here:** `/blog/:slug` detail, person profiles, tag pages. + +## Authorization + +Public. Soft-deleted posts excluded by the API; this screen never sees them.