Live demo: dental-scheduling-saas.vercel.app · Kod: GitHub
Punkt wyjścia
Aplikacje do rezerwacji wyglądają trywialnie, dopóki dwie osoby nie klikną ostatniego terminu w tej samej sekundzie. Wtedy pojawiają się ciekawe pytania: kto naprawdę go dostaje, jak nie pozwolić jednej klinice zobaczyć pacjentów innej, i co się dzieje, gdy płatność przejdzie już po wygaśnięciu blokady? Zbudowałem ten projekt jako skupioną implementację referencyjną, która odpowiada dokładnie na te pytania — multi-tenant SaaS do rezerwacji wizyt dentystycznych, gdzie każda klinika to osobny tenant pod własną ścieżką, pacjenci rezerwują anonimowo, a personel widzi read-only listę swoich nadchodzących wizyt.
Celem był production-ready rdzeń na Vercelu, zbudowany w dwa dni, demonstrujący cztery rzeczy, które wiele „apek CRUD" po cichu robi źle: twardą izolację multi-tenant, atomową blokadę double-bookingu, poprawną kolejność płatności i dostępność w czasie rzeczywistym.
Problem
Każda z tych czterech to miejsce, w którym naiwna wersja wykłada się na produkcji:
- Współbieżność: „sprawdź czy wolne, potem wstaw" ma lukę między sprawdzeniem a zapisem. Pod obciążeniem dwa żądania przechodzą sprawdzenie.
- Izolacja: jeśli filtrowanie tenantów żyje w kodzie aplikacji, jeden zapomniany
WHERE tenant_id = ?przecieka dane między klinikami. - Płatności: jeśli najpierw tworzysz sesję Stripe, a termin rezerwujesz później, dwie osoby mogą dojść do checkoutu i obie zapłacić.
- Świeżość: jeśli kalendarz odświeża się tylko przy przeładowaniu, ludzie rezerwują terminy, których już nie ma.
Rozwiązanie
Motyw przewodni: zepchnij poprawność do bazy danych i trzymaj aplikację cienką. Bez crona, bez workerów w tle — gwarancje żyją tam, gdzie dane.
1. Double-booking jest niemożliwy, nie „mało prawdopodobny"
Partial unique index na (tenant_id, doctor_id, start_time)
ograniczony do aktywnych wierszy (WHERE status IN ('pending','confirmed')) sprawia, że
baza fizycznie odmawia drugiej aktywnej rezerwacji na ten sam termin. Race condition nie jest
łagodzony — jest wyeliminowany. Drugie równoległe żądanie odbija się od indeksu z unique-violation,
co aplikacja zamienia na czysty komunikat „termin właśnie zajęty".
Blokady wygasają po 15 minutach. Zamiast crona, wygaśnięcie rozwiązywane jest just-in-time: booking działa w jednej funkcji Postgresa, która najpierw wygasza nieaktualną blokadę dokładnie na tym slocie, a potem wstawia nową — atomowo, w jednej transakcji.
2. Izolacja tenantów wymuszana przez bazę
Personel loguje się przez Supabase Auth z tenant_id zaszytym w JWT
(app_metadata). Polityki Row Level Security porównują ten claim z
każdym wierszem, więc klinika A dosłownie nie może odczytać danych kliniki B — nawet gdyby aplikacja
o to poprosiła. Pacjenci pozostają anonimowi i nigdy nie dotykają tabel bezpośrednio; server action
autorytatywnie tłumaczy slug z URL na tenant_id. Trzy klienty Supabase, trzy poziomy
zaufania: anon (tylko realtime), authenticated (odczyt personelu pod RLS), service-role (zapisy po
stronie serwera).
3. Najpierw rezerwacja, potem płatność
Kolejność ma znaczenie. Termin jest rezerwowany jako pending przed
przekierowaniem do Stripe Checkout, więc drugi kupujący odbija się od indeksu i nigdy nie trafia na
stronę płatności za zajęty termin. Id sesji Stripe zapisywane jest na wierszu, a użytkownik trafia na
hostowany checkout.
4. Webhook, który przeżyje prawdziwy świat
Webhooki Stripe bywają powtarzane, opóźniane i podszywane. Ten czyta surowe body
żądania i weryfikuje podpis przez constructEvent (z tolerancją na replay), po
czym wykonuje strzeżony, idempotentny update:
SET status='confirmed' WHERE stripe_session_id = $1 AND status='pending'. Replay
aktualizuje zero wierszy i nic nie robi. Paskudny przypadek brzegowy — zapłata po wygaśnięciu blokady
— jest wykrywany i logowany pod udokumentowaną ścieżkę zwrotu.
5. Dostępność, która aktualizuje się sama
Początkowa dostępność renderowana jest po stronie serwera (grid minus aktywne bookingi). W chwili zajęcia terminu serwer emituje zdarzenie Broadcast per-tenant i termin znika u wszystkich, którzy patrzą na danego lekarza — bez pollingu, a anonimowy klient nie czyta przy tym żadnej tabeli.
Dowód, że działa
Deklaracje są tanie, więc repo zawiera skryptowy test end-to-end uruchamiany przeciw żywej produkcji:
tworzy realną rezerwację, sprawdza, że druga na ten sam termin jest odrzucana (23505),
otwiera realną sesję Stripe, wysyła webhook z weryfikacją podpisu i potwierdza, że wiersz przechodzi
w confirmed — następnie powtarza webhook, by udowodnić idempotencję, i sprząta.
9 na 9 testów przechodzi na produkcji.
Architektura
Next.js 14 App Router (Server Components + Server Actions) na Vercelu, Supabase na Postgresa, Auth i
Realtime, Stripe Checkout w trybie testowym. Grid slotów pochodzi ze stałej konfiguracji (Pon–Pt,
9:00–17:00, sloty co 30 min, liczone deterministycznie z Europe/Warsaw do instantu UTC) — bez tabeli
grafików do synchronizacji. UI jest celowo brutalistyczne (czarno-białe, kwadratowe, szybkie) i w
pełni dwujęzyczne PL/EN z przełącznikiem. Deploy odpala się automatycznie przy każdym push na
main.
Wynik
Działający, wdrożony rdzeń SaaS multi-tenant, który wytrzymuje dokładnie te warunki, które wykładają naiwne systemy rezerwacji. Twarde gwarancje — brak double-bookingu, brak wycieków między tenantami, brak podwójnych obciążeń — są wymuszone przez bazę i utwardzony webhook, a nie przez ostrożny kod aplikacji, który jeden refaktor może cofnąć. Zbudowane w jakieś dwa dni.
Sprawdź sam
Jest na żywo i działa w trybie testowym Stripe, więc bez prawdziwych opłat:
- Otwórz klinikę na dental-scheduling-saas.vercel.app, wybierz termin i zarezerwuj.
- Zapłać kartą testową Stripe
4242 4242 4242 4242, dowolna przyszła data, dowolny CVC. - Zaloguj się do panelu personelu:
alfa@klinika.test/test(orazbeta@klinika.test/test) — każda klinika widzi tylko swoje wizyty. - Otwórz tę samą klinikę w dwóch oknach i zarezerwuj termin w jednym — zobacz, jak znika w drugim.
Wnioski
Powracająca lekcja: najmocniejsze gwarancje to te, które robi za ciebie baza danych. Partial unique index usuwa całą klasę race conditions, które logika aplikacji może co najwyżej zmniejszyć. RLS zmienia izolację tenantów z tematu na code review w temat strukturalny. Strzeżony idempotentny update sprawia, że replay webhooka to nic-niedzieje. Każde to kilka linii — i każde zastępuje kategorię incydentów produkcyjnych.
Uczciwe ograniczenia (grafiki per-lekarz, automatyczne zwroty dla przypadku „zapłata po wygaśnięciu", prywatne kanały realtime) są udokumentowane jako świadoma ścieżka rozwoju do wersji enterprise, a nie ukryte — co samo w sobie jest częścią tego, co znaczy „production-ready".