Skip to content
1 change: 1 addition & 0 deletions apps/api/scripts/cutover-dry-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DryRunReport> {
Expand Down
40 changes: 39 additions & 1 deletion apps/api/scripts/import-laddr/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { promisify } from 'node:util';
const exec = promisify(execFile);

import {
BlogPostSchema,
PersonSchema,
ProjectBuzzSchema,
ProjectMembershipSchema,
Expand All @@ -46,6 +47,7 @@ import {
TagSchema,
} from '@cfp/shared/schemas';
import type {
BlogPost,
Person,
Project,
ProjectBuzz,
Expand All @@ -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,
Expand All @@ -73,6 +77,7 @@ import {
import {
newExistingIds,
newIdMaps,
translateBlogPost,
translateBuzz,
translateMembership,
translatePerson,
Expand Down Expand Up @@ -163,6 +168,7 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
'project-memberships': blank(),
'project-updates': blank(),
'project-buzz': blank(),
'blog-posts': blank(),
'tag-assignments': blank(),
};

Expand Down Expand Up @@ -380,6 +386,31 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
}
}

log(`[import] fetching blog from ${opts.sourceHost}`);
const blogPosts: BlogPost[] = [];
for await (const row of fetchAllPages<RawBlogPost>(
'/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.
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -471,6 +502,12 @@ export async function importLaddrFromJson(opts: ImportOptions): Promise<ImportRe
await tx['project-buzz'].upsert({ ...record, projectSlug } as ProjectBuzz);
}

log(`[import] clear + upsert blog-posts (${blogPosts.length})`);
await tx['blog-posts'].clear();
for (const post of blogPosts) {
await tx['blog-posts'].upsert(post);
}

log(`[import] clear + upsert tag-assignments (${tagAssignments.length})`);
await tx['tag-assignments'].clear();
for (const ta of tagAssignments) await tx['tag-assignments'].upsert(ta);
Expand Down Expand Up @@ -510,6 +547,7 @@ function buildCommitMessage(p: CommitParams): string {
`${c['project-memberships']!.imported} project-memberships`,
`${c['project-updates']!.imported} project-updates`,
`${c['project-buzz']!.imported} project-buzz`,
`${c['blog-posts']!.imported} blog-posts`,
`${c['tags']!.imported} tags`,
`${c['tag-assignments']!.imported} tag-assignments`,
].join(', ');
Expand Down Expand Up @@ -622,7 +660,7 @@ async function collectExistingIds(
const projectLegacyByUuid = new Map<string, number>();
const tagLegacyByUuid = new Map<string, number>();

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<Record<string, unknown>> };
for await (const record of sheet.query()) {
Expand Down
25 changes: 25 additions & 0 deletions apps/api/scripts/import-laddr/json-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -149,6 +150,30 @@ export const RawProjectBuzzSchema = z
.passthrough();
export type RawProjectBuzz = z.infer<typeof RawProjectBuzzSchema>;

/**
* 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<typeof RawBlogPostSchema>;

// ---------------------------------------------------------------------------
// Fetcher
// ---------------------------------------------------------------------------
Expand Down
74 changes: 74 additions & 0 deletions apps/api/scripts/import-laddr/translators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import { uuidv7 } from 'uuidv7';

import type {
BlogPost,
Person,
Project,
ProjectBuzz,
Expand All @@ -36,6 +37,7 @@ import type {
} from '@cfp/shared/schemas';

import type {
RawBlogPost,
RawMembership,
RawPerson,
RawProject,
Expand Down Expand Up @@ -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-<ID>`. 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). */
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -191,6 +192,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(tagRoutes);
await fastify.register(projectUpdateRoutes);
await fastify.register(projectBuzzRoutes);
await fastify.register(blogPostRoutes);
await fastify.register(helpWantedRoutes);
await fastify.register(projectMembershipRoutes);
await fastify.register(previewRoutes);
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/plugins/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PersonService } from '../services/person.js';
import { TagService } from '../services/tag.js';
import { ProjectUpdateService } from '../services/project-update.js';
import { ProjectBuzzService } from '../services/project-buzz.js';
import { BlogPostService } from '../services/blog-post.js';
import { HelpWantedService } from '../services/help-wanted.js';
import { ProjectWriteService } from '../services/project.write.js';
import { ProjectMembershipWriteService } from '../services/project-membership.write.js';
Expand All @@ -39,6 +40,7 @@ declare module 'fastify' {
tags: TagService;
projectUpdates: ProjectUpdateService;
projectBuzz: ProjectBuzzService;
blogPosts: BlogPostService;
helpWanted: HelpWantedService;
// Write services
projectsWrite: ProjectWriteService;
Expand Down Expand Up @@ -89,6 +91,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise<void> {
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),
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/routes/blog-posts.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<string, unknown>;
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);
},
);
}
Loading