Dokumentation — So funktioniert's
Zuletzt aktualisiert: 6. August 2026
1. Ablaufübersicht
Ein Termin wird von einem angemeldeten Organisator erstellt und dann über einen eindeutigen öffentlichen Link an einen Gast gesendet. Der Gast braucht kein Konto: Er antwortet über eine öffentliche Seite, und die Antwort erscheint sofort im Dashboard des Organisators.
Organisator (angemeldet)
└─ erstellt einen Termin ──▶ appointments (status = pending, invite_token generiert)
├─ „Per E-Mail senden“ ──▶ Server-Fn sendInviteEmail ──▶ Resend
└─ „Link kopieren“ ──▶ /invite/<token>
Gast (ohne Konto)
└─ öffnet /invite/<token> ──▶ RPC get_invite(_token) (gefilterter öffentlicher Lesezugriff)
└─ Annehmen / Ablehnen ──▶ RPC respond_to_invite(_token, _accept, _message)
└─ status = accepted | declined, responded_at = now()
Organisator
└─ Dashboard: Status-Badge, Antwortdatum, Gastnachricht, Statistiken2. Inhalt der Einladungs-E-Mail
Die E-Mail wird serverseitig in der Funktion sendInviteEmail zusammengestellt und über Resend versendet. Absender: Wert von RESEND_FROM (Standard: „Cadence <onboarding@resend.dev>“). Betreff: „Termin: {{title}}“.
Aufbau des Textkörpers, in dieser Reihenfolge:
- Titel „Terminvorschlag“.
- Begrüßung „Hallo {{guestName}},“.
- Persönlicher Nachrichtenblock (nur für Business-Organisatoren).
- Termintitel fett, dann Datum und Zeitfenster lokal formatiert.
- Ort und Beschreibung, jeweils nur angezeigt, wenn ausgefüllt.
- Grüner Aktionsbutton „Bestätigen oder ablehnen“, der auf den Einladungslink verweist.
- Erinnerung an den Link als Klartext, für Mailclients, die Buttons blockieren.
- „Google-Bewertungen“-Block (Business): Firmenname, Bewertung, Bewertungslink, Maps-Link.
Alle dynamischen Werte werden vor dem Einfügen HTML-escaped: Kein vom Organisator eingegebener Inhalt kann Markup in die E-Mail einschleusen.
Verfügbare Variablen
| Variable | Quelle | Verwendung |
|---|---|---|
| {{guestName}} | contacts.name | Begrüßungszeile „Hallo {{guestName}},“. Leer, wenn kein Kontakt zugeordnet ist. |
| {{title}} | appointments.title | E-Mail-Betreff „Termin: {{title}}“ und fetter Titel im Textkörper. |
| {{dateRange}} | appointments.starts_at / ends_at | Lokal formatiert: „Dienstag, 12. August, 15:00 → 15:30“ (Intl.DateTimeFormat). |
| {{location}} | appointments.location | Zeile „Ort: …“. Der gesamte Block wird ausgelassen, wenn das Feld leer ist. |
| {{description}} | appointments.description | Freitext-Absatz, der unter dem Datum angezeigt wird. Wird ausgelassen, wenn leer. |
| {{inviteLink}} | origin + /invite/ + appointments.invite_token | Button „Bestätigen oder ablehnen“ + Linkerinnerung als Klartext am Ende der E-Mail (kopieren/einfügen). |
| {{inviteMessage}} | profiles.invite_message (Business) | Grüner Block mit persönlicher Nachricht, nur für Business-Organisatoren angezeigt. |
| {{businessName}} / {{googleRating}} / {{googleReviewUrl}} / {{googleMapsUrl}} | profiles.* (Business) | „Google-Bewertungen“-Block in der E-Mail-Fußzeile und auf der Einladungsseite, falls aktiviert. |
Fälle, in denen der Versand nicht abgeschlossen wird
- no_email : der Kontakt hat keine E-Mail-Adresse. Der Organisator muss den Link kopieren und über einen anderen Kanal senden.
- no_api_key : der Versandschlüssel ist nicht konfiguriert. Der Einladungslink wird trotzdem an die Oberfläche zum manuellen Kopieren zurückgegeben.
- provider_error : Ablehnung durch den E-Mail-Anbieter (ungültige Adresse, nicht verifizierte Domain…). Der Terminstatus bleibt unverändert.
- Bei Erfolg wird invite_sent_at mit einem Zeitstempel versehen: Dieses Feld speist die Verfolgung „gesendete Links“ in den Statistiken.
3. Bestätigungsbildschirme (Seite /invite/<token>)
Öffentliche Seite ohne Authentifizierung, gerendert aus der abgesicherten Funktion get_invite, die nur die für die Anzeige nötigen Felder zurückgibt.
- Laden : animiertes Skelett (Kalender), solange die Antwort noch nicht eingegangen ist.
- Ungültiger oder abgelaufener Link : Fehlermeldung; es werden keine Informationen zum Organisator oder Termin preisgegeben.
- Status „Ausstehend“ : Betreff, Datum und Zeitfenster, Dauer, Ort, Name des Organisators, persönliche Nachricht (Business), optionales Freitextfeld (max. 500 Zeichen), dann zwei Buttons „Annehmen“ und „Ablehnen“.
- Nach der Annahme : grünes Badge „Angenommen“, Bestätigung auf dem Bildschirm, Erinnerung an praktische Informationen und Google-Bewertungsblock, falls der Organisator Business ist.
- Nach der Ablehnung : rotes Badge „Abgelehnt“, Hinweis, dass der Organisator benachrichtigt wurde; der Gast kann den Link erneut öffnen und seine Antwort ändern, solange der Termin nicht storniert ist.
- Stornierter Termin : die Antwortbuttons haben keine Wirkung mehr, der angezeigte Status bleibt „Storniert“.
Die vom Gast eingegebene Freitextnachricht wird clientseitig auf 500 Zeichen und datenbankseitig auf 1.000 Zeichen gekürzt, dann dem Organisator auf dem Termindatensatz angezeigt.
4. Terminstatus
| Status | Bezeichnung | Auslöser | Auswirkung |
|---|---|---|---|
| pending | Ausstehend | Bei Terminerstellung (Standardwert in der Datenbank). | Der Link ist aktiv, der Gast kann antworten. Zählt zu „erwartete Antworten“. |
| accepted | Angenommen | Der Gast klickt auf „Annehmen“ — Aufruf von respond_to_invite(_accept = true). | responded_at und response_message werden ausgefüllt; grünes Badge im Dashboard. |
| declined | Abgelehnt | Der Gast klickt auf „Ablehnen“ — respond_to_invite(_accept = false). | Gleicher Eintrag wie bei Annahme, rotes Badge. Der Termin bleibt für eine Nachfrage sichtbar. |
| cancelled | Storniert | Aktion des Organisators von seinem Dashboard aus. | Der Link akzeptiert keine Antwort mehr (respond_to_invite aktualisiert nur andere Status). |
5. Serverseitiger Ablauf
- Erstellen / Bearbeiten / Löschen : durch den authentifizierten Client durchgeführt; die Datenisolierung wird durch Datenbankzugriffsregeln gewährleistet.
- sendInviteEmail: geschützte Serverfunktion. Sie lädt den Termin gefiltert nach der ID des Anfragenden neu, verweigert, falls der Termin ihm nicht gehört, verfasst die E-Mail, ruft Resend auf und versieht den Versand dann mit einem Zeitstempel.
- get_invite(_token): Datenbankfunktion mit erhöhten Rechten, der einzige öffentliche Lesezugang. Sie gibt nur Anzeigefelder zurück und verbirgt Branding-Informationen für einen Nicht-Business-Organisator.
- respond_to_invite(_token, _accept, _message): aktualisiert Status, Nachricht und Antwortzeitstempel. Sie wirkt nur auf die Zeile, die dem Token entspricht, und ignoriert stornierte Termine.
- get_plan_usage(): gibt den Plan, die Monatsnutzung und das Admin-Flag für die Anzeige der Kontingente zurück.
6. Autorisierungslogik
- Organisator : voller Lese-/Schreibzugriff auf seine eigenen Kontakte und Termine, und nur auf diese.
- Nicht angemeldeter Gast : kein direkter Zugriff auf Tabellen. Er kann nur über sein Token einen Termin lesen und darauf antworten. Das Token ist zufällig (36 Hexadezimalzeichen) und gewährt keinen weiteren Zugriff.
- Rollen : in einer eigenen Tabelle gespeichert (Benutzer, Moderator, Administrator) und serverseitig geprüft; sie werden nie aus dem Browser gelesen, um ein Recht zu gewähren.
- Administrator : Zugriff auf den Bereich /admin (alle Konten, Kontakte und Termine) und Befreiung von den Kontingenten des Free-mium-Plans.
- Plan : der Stufenwechsel kann nicht clientseitig erzwungen werden; nur der Stripe-Zahlungsprozess oder ein Administrator kann ihn ändern.
- Kontingente : vor jeder Erstellung datenbankseitig durchgesetzt — 10/15 (Free-mium), 30/50 (Basic), 50/100 (Standard), 250/500 (Economic) Kontakte/Termine pro Kalendermonat, unbegrenzt für Business und Administratoren.
7. Checkliste für das Redaktionsteam
- E-Mail-Text ändern: die HTML-Vorlage der Versandfunktion bearbeiten, nicht die Einladungsseite.
- Bestätigungsbildschirm ändern: die öffentliche Einladungsseite bearbeiten.
- Jede neue Variable muss aus einem bereits von der öffentlichen Lesefunktion bereitgestellten Feld stammen.
- Niemals Daten eines anderen Termins oder Kontakts auf der öffentlichen Seite anzeigen.
- Immer alle drei Zustände testen: ausstehend, angenommen, abgelehnt.