From a33daff28142bc58f1ee738df161e841cb3b83a8 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Thu, 28 May 2026 09:28:45 -0400 Subject: [PATCH 1/4] chore(plans): add legacy-url-redirects in-progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five redirect patterns + a 410 carve-out per behaviors/legacy-id-mapping.md → "Legacy URL forms we accept". Without this, every external bookmark / Google-indexed result / Slack-shared link to the old laddr URL shape 404s at flip-time. Plan covers two new in-memory indices (projectIdByLegacyId, buzzIdBySlug), the onRequest hook pattern-matching the 5 shapes + the 2 deferred-page 410 targets, and tests. Companion to #80 (slug-history redirect) — that one handles renames within the new site; this one handles inbound legacy URLs from the old one. Closes #78. Co-Authored-By: Claude Opus 4.7 (1M context) --- plans/legacy-url-redirects.md | 117 ++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 plans/legacy-url-redirects.md diff --git a/plans/legacy-url-redirects.md b/plans/legacy-url-redirects.md new file mode 100644 index 0000000..5a260d2 --- /dev/null +++ b/plans/legacy-url-redirects.md @@ -0,0 +1,117 @@ +--- +status: in-progress +depends: [] +specs: + - specs/behaviors/legacy-id-mapping.md +issues: [78] +--- + +# 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 + +- [ ] `projectIdByLegacyId` and `buzzIdBySlug` populated at boot for every record with the relevant field. +- [ ] `indexProject` + `indexProjectBuzz` update the new indices in lockstep (re-index after upsert keeps state consistent). +- [ ] Plugin registered in `app.ts` after `services`, alongside `slug-redirect`. +- [ ] All 13 test cases above pass. +- [ ] Existing 255 API tests still pass. +- [ ] `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 (`