# Route map: classic paths, link kinds and the new UI

Plan item A4 of the migration plan (archived: `docs/archive/migration/REWRITE-INTEGRATION.md`, section 7). Written 6 October 2026 from the classic code (`frontend/src/router.js`, `views/Poll.vue`), the backend (`PollAccessResolver`, `Poll.php`, `UiVersionResolver`), the dry run and requests measured against a local copy of the data. Status (7 Oct 2026): **analysis done; the link rules are implemented** (merge `cut/link-segments`): the API layer sends only what the backend can resolve, the three-part link redirects to the public link with query and hash kept, 9 wiring tests and 11 rule tests; checked in a real browser (public, legacy admin and three-part link load, and no request carries the key). **Still open:** the legacy admin decision (section 7.1; today such a link opens as a plain participant view, which is better than the 500 but less than classic), the unknown-key redirect to `/deleted` (7.4), the proposal for the new-UI-only paths (section 4) and the resolver test.

## 1 · Classic routes and where they go

| Classic path | Classic view | In the new UI | Notes |
|---|---|---|---|
| `/pr/:id/:key?/:publicKey?` | `Poll.vue` (all poll types, owner and participants) | `poll-detail`, `poll-detail-participant`, `poll` (three segments) | One path for every poll type and role, so `v3_paths` cannot claim it; `UiVersionResolver` decides per poll (types, hidden polls, polls with tasks or custom fields). Section 2 to 4 are about this route |
| `/pr/new` | `Poll.vue` with `id = new` (create a poll) | `new-poll` (`NewPollView`), claimed in `v3_paths` since 8 Oct | One address for both UIs: a visitor who chose the new UI gets the new panel, with or without an account, as classic allows. Never a poll: the resolver and the router exclude `new`. The new UI's older address `/new` redirects here. Without a session the panel hands the admin key to the editor in the URL (`/pr/{id}/{key}/bearbeiten`), because only that makes the server treat the visitor as the admin |
| `/pr/newde`, `/pr/newen` | `Poll.vue` with `id = new`, language from the path | stay classic (the new panel does not read the language from the path) | Not excluded by the resolver (they count as poll ids; harmless, the poll lookup fails and classic is served). The claim is the bare `pr/new` only |
| `/vpr/:id/:key?` | `TablePoll.vue` | not handled; falls to the classic catch-all | Older table view of a poll; no new-UI counterpart planned |
| `/dashboard` | dashboard | `dashboard` (`HomeView` of the new UI) | In `v3_paths` |
| `/` | redirect to dashboard or login | classic | Not claimed |
| `/signin/:id?/:hash?`, `/login/:mode?`, `/register`, `/reset`, `/resetlink`, `/auth/passwordreset/:token/:email`, `/passwordchanged`, `/emailverify/:id/:hash` | auth screens | stay classic (login stays in classic, session shared) | The new UI's `login` route hands over to `/signin` |
| `/membership`, `/membership/print-invoice-details/:id?/` | account and invoices | `membership`, `invoice-print` exist in the app but are not in `v3_paths` | Mollie returns to `/membership`; classic until the path is switched |
| `/deleted` | "poll deleted" page | `poll-deleted` | Both |
| `/unsubscribe/:id?` | unsubscribe | `unsubscribe` | Links in sent mails; not in `v3_paths` |
| `/agb`, `/dataprivacy` | placeholder pages | `terms`, `privacy` redirect to the website | |
| `/marketing-email`, `/homeold`, `/test-view`, `/chat-test`, `/perf-test/:id/:key?` | test and old screens | classic only | |

## 2 · The second segment of a poll link

Backend builders: `Poll::pollUrl()` gives `/pr/{id}`; `Poll::pollAdminUrl()` gives `/pr/{id}/{hashid(poll.key)}`, or, for polls without a key, `/pr/{id}/{legacyAdminKey}`. Invitation and reminder mails give `/pr/{id}/{invitationId}`. The classic client builds the public link `/pr/{id}/{publicKey}` (`PollMixins.js`, `EditInvites.vue`, `LinkInvite.vue`).

| Kind | Form | How classic recognises it | What the backend does when it receives it | New UI today |
|---|---|---|---|---|
| none | `/pr/{id}` | no segment | 200; the logged-in owner or participant is found by account | works |
| **admin key** | `hashid(poll.key)` | `key == poll.key` after the poll is loaded | 200 and the poll answer carries `key` and `adminKey` | works |
| **invitation id** (personal link, also a sub-admin's) | `hashid(invitation.id)` | an entry of `poll.invitations` has this id; if none, the page goes to `/deleted` | 200; the participant is found by a lookup that is **not limited to this poll** (`invitation_id` is a global key) | works (matched by invitation id, then by participant id); no `/deleted` for an unknown key |
| **public key** | `first4(ownerId) + ownerId` | pattern: characters 0 to 3 equal characters 4 to 7 | **500** "Fails to convert public ids" (the hashids layer cannot decode it) | **500: the page does not load** |
| **legacy admin key** | `last4(ownerId) + ownerId` | pattern: first four equal last four; classic then shows the admin view without asking the server | **500** | **500** |
| unknown | anything else | no invitation matches, so `/deleted` | **500** | **500** (error banner) |

`ownerId` is the hashid of the poll owner's user id. Measured on a local poll with the merged backend; a public key, a legacy admin key and a random string all answered 500, an admin key 200, an invitation id of another poll 200.

**Why classic works:** it never sends a public or legacy key to the API. It tests the two patterns first and passes only the remaining value as `key` (`Poll.vue`, `loadData`). The new UI forwards every segment ("the server decides, never interpret on the client"), which is only valid if the server can decide. It cannot for these two kinds.

## 3 · The three-part link

`/pr/{id}/{key}/{publicKey}` (older links). Classic redirects it with a full page load to `/pr/{id}/{publicKey}`, **keeping the query string and the hash** (so `?classic=1` survives), and drops `key`. The new UI's route `poll` takes the second segment as `participantId` and ignores the third; v3's `router.spec` expects the params `key` and `publicKey` and fails (the one failing test on `cut-prep/integration`).

## 4 · Paths that only the new UI knows

`/pr/{id}/bearbeiten`, `/pr/{id}/{key}/bearbeiten`, `/pr/{id}/teilen`, `/pr/{id}/{key}/teilen` (editor and share view). Classic would read `bearbeiten` as a key, find no invitation and go to `/deleted`; for the four-segment form it would read it as `publicKey` and redirect to `/pr/{id}/bearbeiten`, then `/deleted`. These paths come from navigation inside the new UI, so a reload by a visitor who chose the new UI works (the resolver counts them as poll links). A visitor who chose classic and opens such a link gets "deleted". Proposal: the resolver serves the new UI for these two segments regardless of the stored choice, because classic cannot serve them.

## 5 · Target resolution order in the new UI

Mirrors classic, so every link already sent keeps its meaning:

1. Three segments: redirect to `/pr/{id}/{publicKey}` with query and hash kept.
2. Second segment is `bearbeiten` or `teilen`: the new-UI editor or share view (section 4).
3. Second segment matches the public pattern: public participation; **do not send it to the API**.
4. Second segment matches the legacy admin pattern: admin view as in classic; **do not send it to the API** (decision below).
5. Otherwise send it as `key` (admin key or invitation id). After the poll is loaded: if the answer carries `key`, it was the admin key; else if an invitation or participant matches, it is a personal link; else go to `/deleted`.
6. A deleted poll (`type = 0`) goes to `/deleted` (done).

Implementation: a pure helper `utils/linkSegment.ts` (`isPublicKey`, `isLegacyAdminKey`, `classifySegment`) and **one place** that turns the segment into the API key, used by the poll load, the answers load, the version request and the live sync (all four build `/poll/{id}/{key}/...`). The new UI's `pollKeyStore.ts` (a workaround for the guard problem G8 under token login) becomes unnecessary with the session and can go.

## 6 · Tests to write

- `linkSegment.spec.ts`: each kind with the real forms from section 2 (public `k7Rwk7Rw2XvB4mQz`-style, legacy, admin, invitation, random, too short, empty), including strings shorter than eight characters.
- Router: the three-part redirect keeps `?classic=1` and `#hash`; `bearbeiten` and `teilen` routes; the params of `poll`, `poll-detail`, `poll-detail-participant` (the failing `router.spec`, rewritten to the real rule).
- API layer: with a public or legacy segment no request carries it (poll, answers, version, live sync).
- Poll page: a public link loads (no 500), an unknown key goes to `/deleted`, an admin key shows the admin controls, an invitation id opens the personal row.
- Resolver (PHP): `/pr/{id}/bearbeiten` is served as the new UI for a visitor who chose classic, if the proposal in section 4 is accepted.

## 7 · Decisions and observations

1. **Legacy admin keys.** Classic grants the admin view client-side from the pattern alone, and the backend does not check it. Keeping that for the new UI is parity, but it makes "admin" a client-side claim. Many backend routes that change a poll are unchecked anyway (SEC-007, SEC-013). Decide: parity until classic retires, or no admin controls for legacy keys. Revoking a key is impossible: it is the same for every poll of the owner and changes only if the user id does.
2. **The backend should answer 404 or ignore a key it cannot decode, not 500.** The hashids layer throws `ConverterException` for any route parameter named `key` that is not a hashid. A backend change, to be agreed (rule in `CLAUDE.md`); with the client rule in section 5 it is not needed for the cut.
3. **The invitation lookup is not limited to the poll** (`PollAccessResolver`: `Participant::where('invitation_id', $key)`): an invitation id of another poll is accepted and can identify a participant of that other poll. Worth a finding in the security backlog.
4. **Unknown key to `/deleted`** (parity 1.8, second trigger) needs the invitation list or a participant match in the poll answer; classic uses `poll.invitations`, which the answer carries. Check that the paged answer of the new UI keeps it.
