← Portfolio Case Study

Dental Scheduling SaaS — multi-tenant, w którym nie da się zarezerwować dwa razy

Klient: Projekt własny / implementacja referencyjna Czas: ~2 dni
Next.js 14 TypeScript Supabase PostgreSQL RLS Stripe Realtime Vercel

Przed

  • Dwóch pacjentów płaci za ten sam termin pod obciążeniem (race condition)
  • Dane tenantów wyciekają, bo izolacja siedzi w kodzie aplikacji, nie w bazie
  • Dostępność się starzeje — ludzie rezerwują „duchy", potem zwroty
  • Płatność powstaje zanim termin jest zarezerwowany → podwójne obciążenia

Po

  • Jeden aktywny booking na termin — wymuszony przez bazę, nie przez nadzieję
  • Twarda izolacja tenantów przez RLS w Postgresie na claimie JWT
  • Dostępność na żywo — termin znika w chwili, gdy ktoś go zajmie
  • Termin rezerwowany przed Stripe; webhook idempotentny i odporny na replay
  • Zweryfikowane na produkcji: 9/9 automatycznych testów end-to-end

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 (oraz beta@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".

Masz podobny problem?

Opisz go AI — zbierze kontekst techniczny, Artur przygotuje wycenę w 48h.

Rozpocznij diagnostykę