Zum Hauptinhalt
Zurück zu Projekten
Ustura logo

Ustura

Terminbuchungs-Prototyp

Diagramm des Ustura-Terminprototyps Ustura — randevu prototipi Çakışma kuralı uygulamada değil, veritabanında Müşteri projesi değil; kuralın nereye konduğunu denemek için yazılmış bir prototip. Müşteri — web uygun saati seçer Europe/Istanbul görür Personel — panel manuel randevu, blok, izin aynı anda yazabilir Server Action + Zod girdi doğrulama · UTC'ye çevirme Postgres EXCLUDE USING gist staff_id WITH = tstzrange(starts_at, ends_at) WITH && WHERE status = 'confirmed' Çakışan ikinci yazma reddedilir hangi yoldan gelirse gelsin Kuralın yeri neden veritabanı Uygulama katmanındaki "önce oku, boşsa yaz" kontrolü iki eşzamanlı istek arasında yarış bırakır. Kısıt veritabanında durduğunda web formu, personel paneli, seed script'i ve elle atılan SQL aynı kurala tabidir. Zaman: UTC sakla, yerelde göster timestamptz + tstzrange görüntüleme Europe/Istanbul Env: Zod ile derleme anında zorunlu değişken eksikse build düşer opsiyonel entegrasyon sessizce kapalı Bildirim: e-posta ve web push anahtar yoksa özellik kapanır uygulama yine ayağa kalkar
Die Überschneidungsregel liegt in der Datenbank, nicht in der Anwendung. Sowohl das Kundenformular als auch das Mitarbeiterpanel laufen über denselben Pfad aus Server Action und Zod, die Eingabe wird nach UTC umgerechnet und nach Postgres geschrieben. Ein EXCLUDE-USING-gist-Constraint weist einen zweiten überlappenden bestätigten Termin für dieselbe Person ab, egal woher er kommt — auch Seed-Skripte und manuelles SQL. Zeit wird als timestamptz gespeichert und in Europe/Istanbul angezeigt.

Ein Prototyp für Terminbuchung, den ich für einen Friseur gebaut habe. Kein Kundenprojekt, sondern ein Versuch: Nicht die Aufmerksamkeit des Personals, sondern die Datenbank selbst verhindert, dass ein Termin doppelt vergeben wird.

Verwendete Technologien

  • Next.js 16
  • TypeScript
  • Drizzle ORM
  • Neon Postgres
  • Auth.js
  • Zod
  • date-fns-tz
  • Web Push (VAPID)

Hauptmerkmale

  • Terminüberschneidung ist auf Datenbankebene unmöglich — keine Prüfung in der Anwendungsschicht
  • Zeit wird in UTC gespeichert und in Europe/Istanbul angezeigt
  • Die Verfügbarkeitsberechnung ist eine reine Funktion — testbar ohne Datenbank
  • Umgebungsvariablen werden mit Zod zur Build-Zeit geprüft
  • Personal, Leistungen, Arbeitszeiten, Pausen und Sperren werden im Panel verwaltet
  • Kundschaft kann über einen Einmal-Link stornieren

Entwicklungszeit

1 Monat

Problem

Buchungssysteme brechen an einer Stelle: Zwei Personen nehmen gleichzeitig denselben Termin bei derselben Fachkraft. Das ist kein Fehler, den man durch Szenarien entdeckt; er entsteht aus zwei sich überschneidenden Anfragen und zeigt sich beim Test mit nur einer Person nie. Die übliche Lösung lautet "erst lesen, bei frei schreiben" — und diese Lösung ist falsch, denn im Fenster zwischen Lesen und Schreiben kann eine andere Anfrage denselben Platz belegen. Der zweite Punkt ist die Zeit: Ein Buchungssystem muss Sommerzeitumstellung, Gerätezeitzone und Serverzeitzone gleichzeitig richtig behandeln. Dieses Projekt ist keine Kundenarbeit, sondern ein Prototyp, mit dem ich ausprobiert habe, wo beide Probleme hingehören.

Nutzer

Zwei Parteien. Die Kundschaft: kommt vom Telefon, wählt eine Leistung, sieht freie Zeiten und bucht ohne Konto; will sie später stornieren, braucht sie einen Link. Der Betrieb: verwaltet Personal, Leistungsdauern, Arbeitszeiten, Pausen und geschlossene Tage im Panel; verfolgt den Tag in einer Kalenderansicht und kann Termine manuell eintragen.

Meine Rolle

Einzelner Entwickler, der gesamte Prototyp. Datenmodell und Constraints, Verfügbarkeitsberechnung, Buchungs- und Stornoabläufe, Verwaltungspanel, Benachrichtigungsschicht.

Architektur

Next.js 16 App Router, Neon Postgres und Drizzle. Der Schreibpfad ist Server Action → Zod → Transaktion. Die zentrale Entscheidung liegt im Datenmodell: Die Tabelle `appointments` trägt eine Exclusion-Constraint über Personal-ID und Terminintervall; die Constraint gilt nur für bestätigte Termine, sodass ein stornierter Datensatz keinen Platz blockiert. Die Verfügbarkeitsberechnung liegt vollständig getrennt von der Datenbank: Arbeitszeiten, Pausen, Termine je Fachkraft, Betriebssperren, Slot-Schritt, Mindestvorlauf und Buchungsfenster kommen als Eingabe herein, heraus kommt eine Liste freier Startzeiten. Da die Funktion rein ist, laufen ihre Tests ohne Datenbank. Auf der Zeitseite gilt eine Regel: Alles wird als zeitzonenbehafteter Zeitstempel in UTC gespeichert und in der Oberfläche nach Europe/Istanbul umgerechnet; Tagesgrenzen werden gegen den lokalen Tag berechnet, nicht gegen die Serverzeitzone.

Zentrale Entscheidungen

  1. Der Überschneidungsschutz liegt in der Datenbank, nicht in der Anwendung

    Die Regel ist als Exclusion-Constraint definiert: Zwei sich überschneidende Zeitintervalle können für dieselbe Fachkraft nicht gleichzeitig existieren. Da die Prüfung beim Schreiben in der Datenbank stattfindet, verschwindet das Wettlauffenster zwischen Lesen und Schreiben. Der eigentliche Gewinn ist nicht nur Nebenläufigkeit, sondern Abdeckung: Webformular, manuell im Panel eingetragener Termin, Seed-Skript und ein von Hand abgesetztes SQL unterliegen alle derselben Regel. Eine Prüfung in der Anwendungsschicht schützt nur ihren eigenen Pfad.

    KompromissDer Fehler kommt nun nicht als Validierungsmeldung, sondern als Verletzung einer Datenbank-Constraint; die aufrufende Seite muss sie abfangen und in einen verständlichen Satz übersetzen. Die Constraint verlangt zudem eine Index-Erweiterung für den Intervalltyp, und die Regel selbst steht in einer Migrationsdatei — wer den Code liest, findet sie nicht dort, wo er sie sucht: in der Anwendungsschicht.

  2. Die Verfügbarkeit ist eine reine Funktion

    Verfügbarkeit ist die Stelle mit den meisten Sonderfällen: geschlossene Tage, Pausen, Belegung je Fachkraft, bereits vergangene Zeiten, Mindestvorlauf und das Ende des Buchungsfensters. Diese in eine Datenbankabfrage zu vergraben, hätte jedes Szenario nur mit echten Daten testbar gemacht. Die Berechnung wurde als Ein- und Ausgabe herausgezogen; Tests bauen ein Szenario mit einem einzigen Aufruf auf.

    KompromissDie aus der Datenbank gelesenen Intervalle korrekt in die Funktion zu überführen, liegt in der Verantwortung der aufrufenden Seite; die Funktion merkt nicht, dass sie unvollständige Daten bekommen hat, sie rechnet nur mit dem Gegebenen. Auch wenn die Berechnung getestet ist, muss also separat geprüft werden, dass sie mit der richtigen Eingabe aufgerufen wird.

  3. Die Zeitzonenentscheidung wurde auf einen Satz reduziert

    UTC speichern, Europe/Istanbul anzeigen. Terminintervalle werden als zeitzonenbehaftete Zeitstempel geschrieben, Tagesgrenzen gegen den lokalen Tag berechnet, die Anzeige läuft über eine feste Zone. Dass die Regel ein einziger Satz ist, verhindert, dass jede neue Ansicht ihre eigene Umrechnung erfindet.

    KompromissEine feste Anzeigezone genügt nicht, wenn der Betrieb in einer anderen Zone zu arbeiten beginnt; heute ist die Zone eine Konstante und keine Betriebseinstellung. Auch in Tests muss der Versatz ausdrücklich angegeben werden, sonst hängt ein Test von der Zone der ausführenden Maschine ab.

  4. Umgebungsvariablen werden zur Build-Zeit geprüft

    Da das Konfigurationsschema aus der Konfigurationsdatei importiert wird, scheitert eine fehlerhafte Umgebung beim Build oder Start und nicht zur Laufzeit. Pflichtvariablen sind nur die, die zum Starten der Anwendung nötig sind; Funktionen wie E-Mail, Erinnerungen und Benachrichtigungen sind optional und bleiben aus, bis ihre Schlüssel vorliegen. So lässt sich ein Produktions-Build auch ohne Secrets erzeugen.

    KompromissEin fehlender optionaler Schlüssel schaltet eine Funktion still ab; ein falsch eingetragener und ein nie eingetragener Schlüssel sehen von außen gleich aus. Es gibt außerdem einen Notausgang zum Überspringen der Prüfung, und bleibt dieser Schalter in der Produktion aktiv, entfällt die Garantie des Schemas.

Technologiewahl

Postgres ist hier keine Vorliebe, sondern Voraussetzung: Exclusion-Constraints und Intervalltypen stehen im Zentrum dieser Entscheidung, und dieselbe Garantie lässt sich auf einer anderen Datenbank nicht ebenso günstig herstellen. Drizzle wurde gewählt, weil es erlaubt, die Constraint als reines SQL in einer Migrationsdatei zu schreiben — die Regel verschwindet nicht hinter einer ORM-Abstraktion, sie bleibt lesbar. date-fns-tz bündelt die Umrechnung zwischen lokalem Tag und zeitzonenbehaftetem Zeitstempel an einer Stelle. Auth.js kommt nur auf der Verwaltungsseite zum Einsatz; im Kundenablauf gibt es kein Konto, storniert wird über einen Einmal-Link.

Sicherheit

Das Verwaltungspanel ist sitzungsgeschützt; auf Kundenseite gibt es kein Konto, entsprechend minimal sind die gespeicherten Identitätsdaten. Die Stornierung läuft über einen Link mit einem nicht erratbaren Einmal-Token — die Termin-ID erscheint nie in der Adresszeile. Der Einwilligungstext wird am Datensatz gespeichert: Wer wann welchem Text zugestimmt hat, bleibt nachträglich nicht strittig, und ein eigener Test schützt das. Die Schreibpfade laufen in einer einzigen Transaktion; ein für den Fall gleichzeitiger Buchung geschriebener Test bestätigt, dass die zweite Anfrage an der Datenbank-Constraint scheitert.

Performance

Dies ist ein Prototyp ohne veröffentlichte Installation, und da keine mit Datum versehene Lighthouse-Messung vorliegt, steht hier kein Seitengeschwindigkeits-Score. Messbar ist die Verfügbarkeitsberechnung: An die Datenbank geht eine einzige Abfrage für die Termine und Sperren des Tages, die Slot-Erzeugung passiert im Speicher. Dadurch löst ein Tageswechsel im Kalender keine neue Abfragerunde aus.

Ergebnisse

Aktive Entwicklung
2026-07 · 13 Commits
TypeScript-Quelldateien
133
Testdateien
8 — Verfügbarkeit, Datum, Telefon, Ortszeit, gleichzeitige Buchung, Storno, Verschiebung, Einwilligung
Schema
9 Tabellen · 4 Migrationen
Routen
16 Seiten — Website und Verwaltungspanel
Architekturgarantie
Zwei sich überschneidende bestätigte Termine derselben Fachkraft sind auf Datenbankebene unmöglich
Architekturgarantie
Fehlt eine erforderliche Umgebungsvariable, scheitert der Produktions-Build

Was ich heute anders machen würde

Das eigentliche Ergebnis dieses Prototyps ist kein Code, sondern eine Gewohnheit: zu fragen, ob eine Regel in die Anwendungsschicht oder in die Datenbank gehört. Die Antwort von hier habe ich in spätere Arbeiten mitgenommen — bei Bergaz Lebensmittel habe ich die Idempotenz des Zahlungs-Callbacks auf eine Eindeutigkeits-Constraint reduziert, bei Bergaz Operations die Prüfung auf doppelte Proben auf einen partiellen Unique Index. Es gibt auch Belege in die Gegenrichtung: Bei Taxilos öffnete ein einziges Prädikat, das in dreiwertige Logik fiel, 45 Tore gleichzeitig, weil das Berechtigungs-Gate über Funktionen verteilt war. Anders machen würde ich hier die Zustandsseite: Ein Termin trägt eine Statusmaschine, und die erlaubten Übergänge liegen weiterhin verstreut im Code; auch sie würde ich auf eine einzige Definition reduzieren — genau wie bei der Überschneidungsregel.

Prototyp

Finden wir gemeinsam den richtigen Einstieg

Sagen Sie mir, was Sie bauen möchten; ich sage Ihnen vorab, wie lange es dauert und was der sinnvolle erste Schritt ist.