# Teilnahme und Links — wie Zugriff und Rechte funktionieren

Stand: 21. August 2026
Quellen: `Poll.php`, `Participant.php`, `Invitation.php`, Migrationen, echte API-Responses, Beobachtungen von Andreas.
Jeder Abschnitt ist gekennzeichnet: **belegt** (aus Code oder Daten), **berichtet** (von Andreas beobachtet), **offen**.

---

## 1 · Grundprinzip

**Der Link ist die Autorisierung.** Kein Konto nötig, kein Passwort, keine Registrierung. Wer einen Link hat, hat die damit verbundenen Rechte.

Das ist der zentrale Unterschied zum Wettbewerb: fragab erzeugt standardmäßig ein Passwort pro Abstimmung und verkauft das Abschalten als Abo-Feature. Kulibri braucht das nicht — was gleichzeitig bedeutet, dass die Links selbst das Sicherheitsmodell sind.

---

## 2 · Die Linkformen

Alle Formen folgen dem Muster `{frontend_url}/pr/{pollHashid}[/{zweites Segment}]`.

### 2.1 Einteilig — `/pr/{pollHashid}`
**belegt** (`Poll::pollUrl()`)

```php
$encodedPollId = $hashids->encode($this->id . '');
return config('app.frontend_url') . '/pr/' . $encodedPollId;
```

**berichtet:** Öffnet ein **eingeloggter** Nutzer diesen Link, entscheidet sein Konto über die Rechte. Ist er Admin der Umfrage, darf er alles. Ist er Teilnehmer, bekommt er die Teilnehmeransicht.

**offen:** Was passiert, wenn ein *nicht* eingeloggter Nutzer diesen Link öffnet? Und was, wenn ein eingeloggter Nutzer die Umfrage weder erstellt hat noch Teilnehmer ist?

### 2.2 Admin — `/pr/{pollHashid}/{adminSegment}`
**belegt** (`Poll::pollAdminUrl()`)

```php
if ($this->key) {
    $adminUrl = $this->pollUrl() . '/' . $this->getAdminkey();      // hashid(poll.key)
} else {
    $adminUrl = $this->pollUrl() . '/' . $this->getLegacyAdminkey(); // aus user_id
}
```

**Zwei Varianten mit sehr unterschiedlicher Tragweite:**

| | Bildung | Geltungsbereich |
|---|---|---|
| `getAdminkey()` | Hashid von `poll.key` | pro Umfrage |
| `getLegacyAdminkey()` | Präfix + Hashid von `poll.user_id` | **pro Konto** |

Der Legacy-Schlüssel ist für alle Umfragen desselben Nutzers identisch und ohne Änderung der User-ID nicht widerrufbar. Siehe Befund A3 in `kulibri-backend-befunde.md`.

### 2.3 Persönlicher Teilnahmelink — `/pr/{pollHashid}/{participantId}`
**berichtet**

Sieht aus wie der Admin-Link; das zweite Segment scheint die Teilnehmer-ID zu sein.

Dieser Link ist die Grundlage der Kontaktlisten-Entscheidung: Er soll dort das **alleinige** Autorisierungsmerkmal für den Zugriff auf bestehende Einträge sein, ergänzt um eine dauerhaft sichtbare „Link erneut senden"-Option.

**offen:** Enthält das Segment die `participant.id` oder die `invitation.id`? Die `invitations`-Tabelle hat **kein Token** — nur `id`, `poll_id`, `participant_id`, `email`, `visited`. Der Link muss also aus einer dieser IDs gebildet werden.

### 2.4 Öffentlicher Link — `/pr/{pollHashid}/{???}`
**berichtet, Mechanik unbekannt**

Der Link, den Admins zum Teilen verteilen. Ebenfalls zweiteilig. Führt beim Öffnen direkt auf die Eingabeseite.

**Wichtige Konsequenz:** Der geteilte Link ist damit **nicht** das Ergebnis von `pollUrl()`. Was die Teilen-Ansicht tatsächlich erzeugt, ist bislang nicht nachvollzogen.

---

## 3 · Das zweite Segment ist polymorph

Dies ist der strukturell wichtigste Punkt.

Dieselbe Position in der URL trägt drei verschiedene Bedeutungen:

```
/pr/{pollId}/{adminKey}        →  volle Adminrechte
/pr/{pollId}/{participantId}   →  Rechte dieses einen Teilnehmers
/pr/{pollId}/{publicToken}     →  anonyme Teilnahme
```

Alle drei sind Hashid-artige Zeichenketten ähnlicher Länge und äußerlich nicht unterscheidbar. Die Anwendung muss beim Auflösen also der Reihe nach prüfen, um welche Art Schlüssel es sich handelt.

**Warum das Aufmerksamkeit verdient:**

- Die **Prüfreihenfolge** entscheidet über die Rechte. Ein Fehler dort ist eine Rechteausweitung, kein Anzeigefehler.
- Ein Wert, der zufällig in zwei Räumen gültig ist, wäre nicht als Konflikt erkennbar.
- Für ein neues Frontend muss dokumentiert sein, wie diese Auflösung abläuft. Raten ist hier ausgeschlossen.

**offen:** Wo im Backend findet die Auflösung statt, und in welcher Reihenfolge?

---

## 4 · Wie Teilnehmer entstehen

**belegt** aus Migrationen und Responses.

### Per Einladung
Es entstehen drei Datensätze:

1. `participants` — mit `invited = 1` und `invitation_id`
2. `invitations` — mit `email`, `participant_id`, `visited`
3. `users` — ein Schattenkonto mit `active = 0`, `login_count = 0`, `email_verified_at = null`, auch ohne Registrierung

`Participant::getEmail()` bevorzugt die Adresse aus der Einladung gegenüber der des Kontos.

### Per Selbsteintrag
Nur ein `participants`-Datensatz, `invited = 0`, `invitation_id = null`.

### Mehrfacheinträge
Eine Person kann mehrere Teilnehmer anlegen — belegt in Poll `GY6mLkqWXpEg`, wo zwei Teilnehmer dieselbe `user_id` haben.

**Für Clients wichtig:** Teilnehmer niemals über `user_id` deduplizieren.

---

## 5 · Was beim Öffnen eines Links passiert

**berichtet**

| Link | Zustand | Ergebnis |
|---|---|---|
| Persönlicher Teilnahmelink | immer | direkt im Bearbeitungsmodus auf der eigenen Zeile |
| Öffentlicher Link | per Cookie erkannt | direkt auf der eigenen Zeile |
| Öffentlicher Link | nicht erkannt | neue Zeile, vermutlich als Temp-Teilnehmer |

### Der Ablauf für unbekannte Besucher

1. Öffentlicher Link wird geöffnet, Cookie fehlt oder passt nicht
2. Es entsteht eine neue Zeile — mutmaßlich mit `participants.temp = 1`
3. Der Besucher trägt **zuerst seine Antworten** ein
4. **Danach** wird er nach seinen Angaben gefragt (Name, ggf. E-Mail)
5. Mit dem Absenden werden die Antworten auf einen **neu angelegten regulären Teilnehmer kopiert**, danach wird die Temp-Zeile **gelöscht**

**Damit ist `participants.temp` erklärt** — bisher offene Frage 7. In allen untersuchten Polls stand das Feld auf `0`, weil dort nur abgeschlossene Teilnehmer vorlagen.

### Kopieren und Löschen, nicht Umschalten

Der Übergang ist kein Flag-Wechsel von `1` auf `0`. Es entsteht ein neuer Datensatz, die Antworten wandern hinüber, die Temp-Zeile verschwindet.

**Konsequenz für jedes Frontend:** Die `participant.id` ändert sich zwischen Antworten und Bestätigen. Eine während der Antwortphase gehaltene ID wird ungültig. Nach dem Absenden muss der Client den Teilnehmer neu einlesen und darf die alte ID nicht weiterverwenden — auch nicht in einem Cookie oder im lokalen Zustand.

Dasselbe gilt für `preferences`: Sie hängen an der Teilnehmer-ID und werden mitkopiert. **Ihre `created_at` ist damit der Zeitpunkt des Kopierens, nicht der Zeitpunkt der Antwort** — relevant für die Wartelisten-Sortierung aus Befund F4, weil der vermeintliche Antwortzeitpunkt bei allen kopierten Zeilen identisch ist.

**offen:** Wird beim Bestätigen immer ein neuer Teilnehmer angelegt, oder kann in einen bestehenden zusammengeführt werden? Das entscheidet, was passiert, wenn jemand zweimal über denselben öffentlichen Link antwortet.

### Zwei Beobachtungen dazu

**Die Reihenfolge ist richtig herum.** Erst antworten, dann identifizieren. Wer schon geklickt hat, bricht bei der Namenseingabe seltener ab, als wer vor dem ersten Klick nach seinem Namen gefragt wird. Dasselbe Prinzip wie fragabs Verifikation am Ende und wie „Jetzt selbst teilnehmen" nach dem Erstellen.

**Dasselbe Muster wie beim Poll selbst.** Bei `/api/new` entsteht der Datensatz, bevor ein Typ gewählt ist; hier entsteht der Teilnehmer, bevor ein Name feststeht. Konsistente Denkweise — mit derselben Nebenwirkung: Abbrecher hinterlassen Datensätze.

**Aufgeräumt wird beim Abschluss** — aber nur dort. Was mit Temp-Zeilen von Besuchern passiert, die nie bestätigen, ist offen. Prüfen:

```sql
SELECT temp, COUNT(*) AS zeilen,
       COUNT(DISTINCT poll_id) AS polls,
       MIN(created_at) AS erster, MAX(created_at) AS letzter,
       SUM(created_at = updated_at) AS nie_geaendert
FROM participants
GROUP BY temp;
```

Ist die Zahl hoch, verfälscht sie eure Teilnehmerstatistiken — und beantwortet zugleich, wie viele Menschen eine Umfrage öffnen, antworten und dann doch abspringen.

**Risiko, das mit geprüft werden sollte:** Wenn bereits das Öffnen des Links eine Zeile erzeugt, legen auch Suchmaschinen-Crawler und Link-Vorschauen von WhatsApp oder Slack Teilnehmer an. Falls `nie_geaendert` bei `temp = 1` sehr hoch ist, ist das der wahrscheinliche Grund.

---

## 5a · Bearbeiten der eigenen Antwort

**berichtet:** Ohne Konto ist das Bearbeiten nur vom **selben Endgerät** aus möglich — die Wiedererkennung läuft über einen Cookie.

**offen:** Was steht in dem Cookie — Teilnehmer-ID, ein Token, etwas anderes? Wie lange gilt er? Und ist er pro Umfrage oder global?

Das ist die Lücke gegenüber fragab, das dafür ein kurzes Passwort vergibt. Der diskutierte Gegenvorschlag: ein Link **pro eintragender Person**, nicht pro Eintrag — dann öffnet ein Link alle Einträge dieser Person und das Gruppierungsproblem bei Mehrfacheinträgen entsteht gar nicht erst.

Der beste Moment, ein kostenloses Konto anzubieten, ist genau nach Schritt 4 oben: Der Teilnehmer hat gerade geantwortet, seine Angaben gemacht und steht vor der Frage, wie er später wieder herankommt. Das ist kein Upsell, sondern die Lösung eines Problems, das er in diesem Augenblick hat — und zugleich der Teilnehmer-zu-Admin-Übergang, auf dem euer Wachstum beruht.

---

## 6 · Offene Punkte, priorisiert

| # | Frage | Warum relevant |
|---|---|---|
| 1 | Wie wird der öffentliche Link gebildet? | Der meistgenutzte Link überhaupt — und unbekannt |
| 2 | In welcher Reihenfolge löst das Backend das zweite Segment auf? | Fehler dort = Rechteausweitung |
| 3 | Enthält der persönliche Link `participant.id` oder `invitation.id`? | Blockiert die Kontaktliste |
| 4 | Was steht im Wiedererkennungs-Cookie, wie lange gilt er, pro Umfrage oder global? | Entscheidet über die geräteübergreifende Lösung |
| 5 | Ist das API-Feld `adminKey` dasselbe wie das URL-Segment? | In beiden untersuchten Polls kam der **Legacy**-Wert zurück, obwohl `poll.key` gesetzt war — nach Code müsste dort `getAdminkey()` greifen |
| 6 | Wann wird `invitation.visited` gesetzt? | Entscheidet, ob „hat noch nicht geantwortet" stimmt |
| 7 | ~~Wozu dient `participants.temp`?~~ | **Beantwortet:** unfertiger Teilnehmer, entsteht beim Öffnen des öffentlichen Links vor der Namenseingabe |
| 9 | Werden Temp-Zeilen von **Abbrechern** aufgeräumt? Erzeugen Crawler und Link-Vorschauen welche? | Verfälscht Teilnehmerzahlen. Beim Abschluss wird gelöscht, sonst unklar |
| 10 | Legt der Abschluss immer einen neuen Teilnehmer an, oder wird in einen bestehenden zusammengeführt? | Entscheidet das Verhalten bei zweimaliger Antwort über denselben Link |
| 8 | Einteiliger Link ohne Login oder ohne Bezug zur Umfrage? | Unklares Verhalten in einem sicherheitsrelevanten Pfad |

Punkte 1 bis 3 blockieren konkrete Arbeit und sollten zuerst geklärt werden.

---

## 7 · Was das für den Rewrite bedeutet

**Nicht raten.** Dieser Bereich ist das Sicherheitsmodell des Produkts. Ein Coding-Agent, der eine Linkform plausibel rekonstruiert, kann Rechte verschieben, ohne dass ein Fehler sichtbar wird.

**Regel für Bolt:** Linkbildung und Rechteauflösung werden nicht implementiert, bevor die Punkte 1 bis 3 beantwortet sind. Bis dahin nur bestehende Links konsumieren, keine neuen erzeugen.
