diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e29b40d..d18ceeb 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -39,6 +39,7 @@ import pushDaemonPlugin from './plugins/push-daemon.js'; import servicesPlugin from './plugins/services.js'; import markdownPlugin from './plugins/markdown.js'; import slugRedirectPlugin from './plugins/slug-redirect.js'; +import legacyRedirectPlugin from './plugins/legacy-redirect.js'; import rateLimitPlugin from './plugins/rate-limit.js'; import idempotencyPlugin from './plugins/idempotency.js'; import sessionMiddlewarePlugin from './auth/middleware.js'; @@ -133,6 +134,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise → /projects/ + * /people/:username[/...] → /members/:username[/...] + * /project-updates?ProjectID= → /projects/ + * /project-buzz/[/...] → /projects//buzz/[/...] + * /tags/.[/...] → /tags//[/...] + * + * Plus `410 Gone` for explicitly-deferred patterns (`/checkin`, + * `/bigscreen`) — see specs/deferred.md for why. + * + * Companion to slug-redirect.ts (renames *within* the new site). The two + * hooks pattern-match disjoint URL shapes — they coexist without + * coordination, both bypass /api/*, and both register before the + * static-web SPA fallthrough. + */ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import fp from 'fastify-plugin'; + +import type { InMemoryState } from '../store/memory/state.js'; + +/** Long cache — legacy URL shapes are permanent. */ +const REDIRECT_CACHE = 'public, max-age=86400'; + +/** 410 body — minimal explanation page. */ +const GONE_HTML = ` + + + + This page is no longer available + + + + +

This page is no longer available

+

+ The page you're looking for was part of an older version of + codeforphilly.org that's been + retired. The feature isn't coming back in its old form, but you can + still find current Code for Philly projects, events, and people from + the home page. +

+ +`; + +const GONE_PATHS = new Set(['/checkin', '/bigscreen']); + +/** Strip the query off a URL string, returning { path, query } (query keeps the leading ?). */ +function splitUrl(url: string): { path: string; query: string } { + const idx = url.indexOf('?'); + if (idx === -1) return { path: url, query: '' }; + return { path: url.slice(0, idx), query: url.slice(idx) }; +} + +/** + * Remove a single query-string parameter while preserving the rest. Returns + * the query suffix including the leading `?`, or '' if no params remain. + */ +function dropQueryParam(query: string, param: string): string { + if (!query) return ''; + const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query); + params.delete(param); + const remaining = params.toString(); + return remaining ? `?${remaining}` : ''; +} + +async function legacyRedirectPlugin(fastify: FastifyInstance): Promise { + fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => { + if (request.method !== 'GET' && request.method !== 'HEAD') return; + if (request.url.startsWith('/api/')) return; + + const { path, query } = splitUrl(request.url); + const state = fastify.inMemoryState; + + // /checkin, /bigscreen → 410 Gone ----------------------------------------- + if (GONE_PATHS.has(path)) { + await reply + .code(410) + .type('text/html; charset=utf-8') + .header('Cache-Control', 'public, max-age=86400') + .send(GONE_HTML); + return; + } + + // /projects?ID= → /projects/ ------------------------------------- + if (path === '/projects' && query) { + const id = legacyIdFromQuery(query, 'ID'); + if (id !== null) { + const slug = projectSlugByLegacyId(state, id); + if (slug) { + const remainingQuery = dropQueryParam(query, 'ID'); + await sendRedirect(reply, `/projects/${slug}${remainingQuery}`); + return; + } + // Unknown legacyId — fall through to SPA; nothing useful to redirect to. + } + } + + // /project-updates?ProjectID= → /projects/ ----------------------- + if (path === '/project-updates' && query) { + const id = legacyIdFromQuery(query, 'ProjectID'); + if (id !== null) { + const slug = projectSlugByLegacyId(state, id); + if (slug) { + const remainingQuery = dropQueryParam(query, 'ProjectID'); + await sendRedirect(reply, `/projects/${slug}${remainingQuery}`); + return; + } + } + } + + // /people/[/...] → /members/[/...] -------------------- + // Pure prefix rewrite — laddr's Username was copied verbatim into slug + // per behaviors/slug-handles.md#migration-from-laddr, so no lookup needed. + const peopleMatch = /^\/people\/([^/]+)(\/.*)?$/.exec(path); + if (peopleMatch) { + const username = peopleMatch[1] as string; + const suffix = peopleMatch[2] ?? ''; + await sendRedirect(reply, `/members/${username}${suffix}${query}`); + return; + } + + // /project-buzz/[/...] → /projects//buzz/[/...] -- + const buzzMatch = /^\/project-buzz\/([^/]+)(\/.*)?$/.exec(path); + if (buzzMatch) { + const buzzSlug = buzzMatch[1] as string; + const suffix = buzzMatch[2] ?? ''; + const buzzId = state.buzzIdBySlug.get(buzzSlug); + if (buzzId !== undefined) { + const buzz = state.projectBuzz.get(buzzId); + if (buzz) { + const projectSlug = state.projectSlugById.get(buzz.projectId); + if (projectSlug) { + await sendRedirect( + reply, + `/projects/${projectSlug}/buzz/${buzzSlug}${suffix}${query}`, + ); + return; + } + } + } + // Unknown buzz slug — fall through (SPA serves 404 or its own handling). + } + + // /tags/.[/...] → /tags//[/...] --------- + // Pure URL transform; no lookup. The legacy dot-form was laddr's tag + // handle shape; the new site uses path-form for routing distinction. + const dotTagMatch = /^\/tags\/([a-z]+)\.([^/]+)(\/.*)?$/.exec(path); + if (dotTagMatch) { + const namespace = dotTagMatch[1] as string; + const slug = dotTagMatch[2] as string; + const suffix = dotTagMatch[3] ?? ''; + await sendRedirect(reply, `/tags/${namespace}/${slug}${suffix}${query}`); + return; + } + }); +} + +async function sendRedirect(reply: FastifyReply, target: string): Promise { + await reply + .code(301) + .header('Location', target) + .header('Cache-Control', REDIRECT_CACHE) + .send(); +} + +/** + * Parse an integer legacy-id from a query string. Returns null for absent, + * non-numeric, negative, or NaN values — those fall through to the SPA + * rather than triggering a redirect to an invalid target. + */ +function legacyIdFromQuery(query: string, param: string): number | null { + const params = new URLSearchParams(query.startsWith('?') ? query.slice(1) : query); + const raw = params.get(param); + if (raw === null) return null; + if (!/^\d+$/.test(raw)) return null; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return null; + return n; +} + +function projectSlugByLegacyId(state: InMemoryState, legacyId: number): string | null { + const projectId = state.projectIdByLegacyId.get(legacyId); + if (!projectId) return null; + return state.projectSlugById.get(projectId) ?? null; +} + +export default fp(legacyRedirectPlugin, { + name: 'legacy-redirect', + dependencies: ['services'], +}); diff --git a/apps/api/src/store/memory/state.ts b/apps/api/src/store/memory/state.ts index 9002806..4aea172 100644 --- a/apps/api/src/store/memory/state.ts +++ b/apps/api/src/store/memory/state.ts @@ -43,6 +43,14 @@ export interface InMemoryState { projectSlugById: Map; /** project.slug → project.id */ projectIdBySlug: Map; + /** + * project.legacyId → project.id. Populated only for records that carry a + * laddr legacy ID (the importer sets these; runtime-created projects don't). + * Used by the legacy-redirect plugin to resolve `/projects?ID=` and + * `/project-updates?ProjectID=` to the canonical slug URL. + * Per specs/behaviors/legacy-id-mapping.md. + */ + projectIdByLegacyId: Map; /** person.id → person.slug */ personSlugById: Map; @@ -66,6 +74,13 @@ export interface InMemoryState { buzzByProject: Map>; /** projectId + buzzSlug → buzzId */ buzzByProjectAndSlug: Map; + /** + * buzz.slug → buzz.id (global). Buzz slugs are globally unique per + * `data-model.md#projectbuzz`, so a flat map is the right shape for the + * legacy `/project-buzz/` redirect (which carries only the buzz slug + * with no project hint). + */ + buzzIdBySlug: Map; /** projectId → Set */ helpWantedByProject: Map>; @@ -109,6 +124,7 @@ export function createEmptyState(): InMemoryState { projectSlugById: new Map(), projectIdBySlug: new Map(), + projectIdByLegacyId: new Map(), personSlugById: new Map(), personIdBySlug: new Map(), tagIdByHandle: new Map(), @@ -118,6 +134,7 @@ export function createEmptyState(): InMemoryState { updateByProjectAndNumber: new Map(), buzzByProject: new Map(), buzzByProjectAndSlug: new Map(), + buzzIdBySlug: new Map(), helpWantedByProject: new Map(), tagAssignmentsByTaggable: new Map(), tagAssignmentsByTag: new Map(), @@ -147,10 +164,16 @@ export function indexProject(state: InMemoryState, project: Project): void { if (old) { state.projectSlugById.delete(old.id); state.projectIdBySlug.delete(old.slug); + if (typeof old.legacyId === 'number') { + state.projectIdByLegacyId.delete(old.legacyId); + } } state.projects.set(project.id, project); state.projectSlugById.set(project.id, project.slug); state.projectIdBySlug.set(project.slug, project.id); + if (typeof project.legacyId === 'number') { + state.projectIdByLegacyId.set(project.legacyId, project.id); + } } /** Add or replace one person and update their secondary indices. */ @@ -212,6 +235,10 @@ export function indexProjectUpdate(state: InMemoryState, update: ProjectUpdate): /** Add or replace a buzz item and update secondary indices. */ export function indexProjectBuzz(state: InMemoryState, buzz: ProjectBuzz): void { + const old = state.projectBuzz.get(buzz.id); + if (old) { + state.buzzIdBySlug.delete(old.slug); + } state.projectBuzz.set(buzz.id, buzz); let byProject = state.buzzByProject.get(buzz.projectId); @@ -220,6 +247,7 @@ export function indexProjectBuzz(state: InMemoryState, buzz: ProjectBuzz): void const key = `${buzz.projectId}:${buzz.slug}`; state.buzzByProjectAndSlug.set(key, buzz.id); + state.buzzIdBySlug.set(buzz.slug, buzz.id); } /** Add or replace a help-wanted role and update secondary indices. */ diff --git a/apps/api/src/store/state-apply.ts b/apps/api/src/store/state-apply.ts index 7a0383b..c8968b3 100644 --- a/apps/api/src/store/state-apply.ts +++ b/apps/api/src/store/state-apply.ts @@ -61,9 +61,13 @@ export class StateApply { removeProject(projectId: string, slug: string): this { this.#ops.push((state, fts) => { + const old = state.projects.get(projectId); state.projects.delete(projectId); state.projectSlugById.delete(projectId); state.projectIdBySlug.delete(slug); + if (old && typeof old.legacyId === 'number') { + state.projectIdByLegacyId.delete(old.legacyId); + } fts.removeProject(slug); }); this.#invalidateFacets = true; @@ -183,6 +187,7 @@ export class StateApply { state.projectBuzz.delete(b.id); state.buzzByProject.get(b.projectId)?.delete(b.id); state.buzzByProjectAndSlug.delete(`${b.projectId}:${b.slug}`); + state.buzzIdBySlug.delete(b.slug); }); return this; } diff --git a/apps/api/tests/legacy-redirect.test.ts b/apps/api/tests/legacy-redirect.test.ts new file mode 100644 index 0000000..7904643 --- /dev/null +++ b/apps/api/tests/legacy-redirect.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for the legacy laddr URL redirect plugin + * (apps/api/src/plugins/legacy-redirect.ts) — implements + * specs/behaviors/legacy-id-mapping.md → "Legacy URL forms we accept". + * + * Covers all 5 redirect patterns + the /checkin /bigscreen 410 + the + * /api/* bypass + unknown-legacyId pass-through. + */ +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 { seedRawToml } from './helpers/seed-fixtures.js'; + +const NOW = '2026-05-27T00:00:00Z'; + +let dataRepo: { path: string; cleanup: () => Promise }; +let privateStore: { path: string; cleanup: () => Promise }; +let app: FastifyInstance | undefined; + +async function bootApp(): Promise { + return 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', + }, + }); +} + +async function seedProject(slug: string, id: string, legacyId?: number): Promise { + const lines = [ + `id = "${id}"`, + `slug = "${slug}"`, + `title = "Test ${slug}"`, + `stage = "testing"`, + `featured = false`, + `createdAt = "${NOW}"`, + `updatedAt = "${NOW}"`, + ]; + if (legacyId !== undefined) lines.push(`legacyId = ${legacyId}`); + await seedRawToml( + dataRepo.path, + `projects/${slug}.toml`, + lines.join('\n'), + `seed project ${slug}`, + ); +} + +async function seedBuzz(opts: { + projectId: string; + projectSlug: string; + buzzId: string; + buzzSlug: string; +}): Promise { + await seedRawToml( + dataRepo.path, + `project-buzz/${opts.projectSlug}/${opts.buzzSlug}.toml`, + [ + `id = "${opts.buzzId}"`, + `projectId = "${opts.projectId}"`, + `slug = "${opts.buzzSlug}"`, + `headline = "Test buzz ${opts.buzzSlug}"`, + `url = "https://example.com/${opts.buzzSlug}"`, + `publishedAt = "${NOW}"`, + `createdAt = "${NOW}"`, + `updatedAt = "${NOW}"`, + ].join('\n'), + `seed buzz ${opts.buzzSlug}`, + ); +} + +beforeEach(async () => { + dataRepo = await createFullDataRepo(); + privateStore = await createPrivateStorageDir(); +}); + +afterEach(async () => { + if (app) { + await app.close(); + app = undefined; + } + await dataRepo.cleanup(); + await privateStore.cleanup(); +}); + +describe('legacy-redirect plugin', () => { + describe('/projects?ID=', () => { + it('301s to /projects/', async () => { + const id = '01951a3c-0000-7000-8000-000000000301'; + await seedProject('my-project', id, 42); + app = await bootApp(); + + const res = await app.inject({ method: 'GET', url: '/projects?ID=42' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/projects/my-project'); + }); + + it('preserves other query params, drops ID from the result', async () => { + const id = '01951a3c-0000-7000-8000-000000000302'; + await seedProject('mp', id, 7); + app = await bootApp(); + + const res = await app.inject({ method: 'GET', url: '/projects?ID=7&tab=updates' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/projects/mp?tab=updates'); + }); + + it('passes through unknown legacyIds (no redirect)', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/projects?ID=99999' }); + expect(res.statusCode).not.toBe(301); + }); + + it('passes through non-numeric ID values', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/projects?ID=notanumber' }); + expect(res.statusCode).not.toBe(301); + }); + }); + + describe('/people/:username', () => { + it('301s to /members/:username', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/people/janedoe' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/members/janedoe'); + }); + + it('preserves sub-routes', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/people/janedoe/edit' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/members/janedoe/edit'); + }); + + it('preserves query string', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/people/janedoe?tab=projects' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/members/janedoe?tab=projects'); + }); + }); + + describe('/project-updates?ProjectID=', () => { + it('301s to /projects/', async () => { + const id = '01951a3c-0000-7000-8000-000000000401'; + await seedProject('updates-target', id, 11); + app = await bootApp(); + + const res = await app.inject({ method: 'GET', url: '/project-updates?ProjectID=11' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/projects/updates-target'); + }); + + it('passes through unknown legacyIds', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/project-updates?ProjectID=99' }); + expect(res.statusCode).not.toBe(301); + }); + }); + + describe('/project-buzz/', () => { + it('301s to /projects//buzz/', async () => { + const projectId = '01951a3c-0000-7000-8000-000000000501'; + const buzzId = '01951a3c-0000-7000-8000-000000000502'; + await seedProject('news-project', projectId, undefined); + await seedBuzz({ + projectId, + projectSlug: 'news-project', + buzzId, + buzzSlug: 'press-mention', + }); + app = await bootApp(); + + const res = await app.inject({ method: 'GET', url: '/project-buzz/press-mention' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/projects/news-project/buzz/press-mention'); + }); + + it('passes through unknown buzz slugs', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/project-buzz/nonexistent' }); + expect(res.statusCode).not.toBe(301); + }); + }); + + describe('/tags/.', () => { + it('301s topic.* to /tags/topic/*', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/tags/topic.transit' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/tags/topic/transit'); + }); + + it('301s tech.* to /tags/tech/*', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/tags/tech.flutter' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/tags/tech/flutter'); + }); + + it('301s event.* to /tags/event/*', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/tags/event.ecocamp-2014' }); + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/tags/event/ecocamp-2014'); + }); + + it('does not match /tags/topic/transit (already path-form)', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/tags/topic/transit' }); + expect(res.statusCode).not.toBe(301); + }); + }); + + describe('410 Gone for deferred patterns', () => { + it('serves 410 for /checkin', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/checkin' }); + expect(res.statusCode).toBe(410); + expect(res.headers['content-type']).toContain('text/html'); + expect(res.body).toContain('no longer available'); + }); + + it('serves 410 for /bigscreen', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/bigscreen' }); + expect(res.statusCode).toBe(410); + }); + }); + + describe('/api/* bypass', () => { + it('does not intercept /api/projects', async () => { + const id = '01951a3c-0000-7000-8000-000000000901'; + await seedProject('apitest', id, 42); + app = await bootApp(); + + // /api/projects with a real legacyId-style query — never 301s. + // (The actual API route handles its own routing; this confirms the + // legacy-redirect hook stays out of the API surface.) + const res = await app.inject({ method: 'GET', url: '/api/projects?ID=42' }); + expect(res.statusCode).not.toBe(301); + }); + + it('does not intercept /api/people/:username (no /members rewrite for API)', async () => { + app = await bootApp(); + const res = await app.inject({ method: 'GET', url: '/api/people/janedoe' }); + expect(res.statusCode).not.toBe(301); + }); + }); +}); diff --git a/plans/legacy-url-redirects.md b/plans/legacy-url-redirects.md new file mode 100644 index 0000000..cf84278 --- /dev/null +++ b/plans/legacy-url-redirects.md @@ -0,0 +1,127 @@ +--- +status: done +depends: [] +specs: + - specs/behaviors/legacy-id-mapping.md +issues: [78] +pr: 93 +--- + +# Plan: Legacy laddr URL redirects + +## Scope + +[`specs/behaviors/legacy-id-mapping.md`](../specs/behaviors/legacy-id-mapping.md) → "Legacy URL forms we accept" lists the laddr URL shapes the new site must continue serving. None are wired today — every external bookmark, indexed Google result, and Slack/Twitter link to the old site shapes 404s on next-v2 (and will 404 on `codeforphilly.org` at flip-time without this). + +Five redirect patterns + a 410 carve-out for explicitly-deferred URLs: + +| Legacy URL | Resolved to | Lookup | +|---|---|---| +| `/projects?ID=` | `/projects/` | `projects.legacyId = n` | +| `/people/:username` | `/members/:username` | static rewrite — username = slug | +| `/project-updates?ProjectID=` | `/projects/` | by project `legacyId` | +| `/project-buzz/` | `/projects//buzz/` | buzz slug is globally unique | +| `/tags/.` (dot-form) | `/tags//` | pure URL transform, no lookup | +| `/checkin`, `/bigscreen` | `410 Gone` + explanation | deferred per [specs/deferred.md](../specs/deferred.md) | + +Companion to [#80](https://github.com/CodeForPhilly/codeforphilly-ng/issues/80) (slug-history redirect — handles renames *within* the new site). This handles redirects *into* the new site from the old one. + +Closes [#78](https://github.com/CodeForPhilly/codeforphilly-ng/issues/78). + +## Implements + +- [behaviors/legacy-id-mapping.md](../specs/behaviors/legacy-id-mapping.md) — all 5 redirect rows + the 410 carve-out for deferred patterns. + +## Approach + +### 1. Two new in-memory indices + +`InMemoryState` already has `projectIdBySlug`, `personIdBySlug`, `buzzByProjectAndSlug`. The legacy redirect needs: + +- `projectIdByLegacyId: Map` — for `/projects?ID=` + `/project-updates?ProjectID=`. +- `buzzIdBySlug: Map` — for `/project-buzz/`. Buzz slugs are globally unique per `data-model.md#projectbuzz`, so a flat global map is the right shape. + +People don't need a legacy-id index because the username → slug mapping is a static rewrite (laddr's `Username` was copied verbatim into `slug` on import per `behaviors/slug-handles.md#migration-from-laddr`). + +Tags don't need an index because the dot-form → path-form transform is pure URL surgery (`topic.transit` → `topic/transit`) — no lookup. + +`indexProject` populates `projectIdByLegacyId` when `record.legacyId` is set. `indexProjectBuzz` populates `buzzIdBySlug`. Boot loader picks them up via the existing `loadInMemoryState` flow without changes (the existing `indexProject` + `indexProjectBuzz` calls already fire for every record). + +### 2. `legacy-redirect` Fastify plugin + +`apps/api/src/plugins/legacy-redirect.ts` — registered after `services`, before `slug-redirect` (the order between the two doesn't matter operationally; both bypass `/api/*`). + +Each pattern is encoded as a separate matcher; the hook tries them in order and replies 301 (or 410) on first hit. Patterns: + +1. **`/projects?ID=`** — match path `/projects` exactly + parse `request.query.ID`. Lookup `projectIdByLegacyId.get(Number(id))` → project; rebuild as `/projects/`. +2. **`/people/...`** — regex `/^\/people\/([^/]+)(\/.*)?$/`. Rebuild as `/members/`. No lookup; static prefix-rewrite. +3. **`/project-updates?ProjectID=`** — match path `/project-updates` + parse `request.query.ProjectID`. Lookup project → `/projects/`. +4. **`/project-buzz/...`** — regex `/^\/project-buzz\/([^/]+)(\/.*)?$/`. Look up `buzzIdBySlug.get(slug)` → buzz; get `project.slug` from `projectSlugById`. Rebuild as `/projects//buzz/`. +5. **`/tags/....`** — regex `/^\/tags\/([a-z]+)\.([^/]+)(\/.*)?$/`. Pure transform — rebuild as `/tags//`. +6. **`/checkin`, `/bigscreen`** — exact match → `410 Gone` with a small explanation HTML body. Spec doesn't specify the exact body; we'll serve a minimal page linking to the current site root. + +All redirects respond with `301` + `Location` + `Cache-Control: max-age=86400` (24h — legacy URL shapes are permanent and won't change between deploys; the cache is conservative but a full year would be presumptuous). + +For unknown legacy-IDs (`?ID=99999` where no project exists), the hook returns without sending — request continues to the SPA fallthrough, which 404s. Spec doesn't require a different shape for "legacy ID not found"; treating it the same as any non-existent slug is consistent. + +### 3. Plugin registration order + +``` +... services → + legacy-redirect (new) → + slug-redirect (existing) → + static-web (SPA fallthrough) +``` + +Both `legacy-redirect` and `slug-redirect` are `onRequest` hooks; they each pattern-match disjoint URL shapes (the legacy patterns have query strings or dot-form or specific prefixes that the slug-redirect patterns never match). No coordination needed beyond "register them both." + +### 4. Tests + +`apps/api/tests/legacy-redirect.test.ts`: + +- `/projects?ID=42` with project legacyId=42 → 301 to `/projects/` +- `/projects?ID=42` with no matching project → no redirect (passes through to SPA) +- `/projects?ID=notanumber` → no redirect (passes through; treat as garbage query) +- `/people/janedoe` → 301 to `/members/janedoe` (sub-route preserved: `/people/janedoe/edit` → `/members/janedoe/edit`) +- `/project-updates?ProjectID=7` → 301 to `/projects/` (lookups via projectIdByLegacyId) +- `/project-buzz/inquirer-praises-foo` → 301 to `/projects/foo-project/buzz/inquirer-praises-foo` +- `/tags/topic.transit` → 301 to `/tags/topic/transit` +- `/tags/tech.flutter` → 301 to `/tags/tech/flutter` +- `/tags/event.ecocamp-2014` → 301 to `/tags/event/ecocamp-2014` +- `/checkin` → 410 +- `/bigscreen` → 410 +- `/api/projects?ID=42` → no redirect (API path bypass) +- Query string preservation across the project lookup pattern: `/projects?ID=42&tab=updates` → `/projects/?tab=updates` (drops `ID` since it's now in the path, keeps other params) + +## Validation + +- [x] `projectIdByLegacyId` and `buzzIdBySlug` populated at boot via `indexProject` + `indexProjectBuzz`. +- [x] `indexProject` + `indexProjectBuzz` update both old- and new-index entries on upsert; `removeProject` + `removeProjectBuzz` ops in `state-apply.ts` clean up the new indices too. +- [x] Plugin registered in `app.ts` after `services`, alongside `slug-redirect`. +- [x] 19 test cases pass (covering all 5 redirect patterns + 410 + /api/* bypass + unknown-legacyId pass-through + sub-route + query-string preservation). +- [x] All 274 API tests pass (255 pre-existing + 19 new). +- [x] `npm run type-check && npm run lint` clean. + +## Risks / unknowns + +- **`?ID=` query-string parsing** — Fastify decodes query params for us, but bot traffic with arbitrary `ID` values (`