Akceptacja regulaminu po zapisie przez recepcję
Zapis klienta na zajęcia stałe dokonany przy ladzie jest warunkowy. Miejsce jest trzymane, ale zapis staje się wiążący dopiero wtedy, gdy klient zaakceptuje regulamin z linku, który dostaje SMS-em. Brak akceptacji w wyznaczonym czasie oznacza automatyczne wypisanie i SMS z informacją o anulowaniu.
Część 1. Instrukcja dla recepcji
Jak to wygląda w praktyce
- Zapisujesz klienta tak jak dotychczas — w kalendarzu, przez edycję zajęć i listę uczestników. Przy zajęciach indywidualnych liczy się też założenie nowych zajęć od razu z uczestnikiem na liście, pojedynczego terminu i całej serii cyklicznej: lekcję zakłada się razem z osobą, dla której powstaje, więc to jest właśnie ten zapis.
- Po zapisaniu system wysyła klientowi jeden SMS z linkiem do regulaminu. Klient dostaje osobny SMS na każdego uczestnika, ale tylko jeden na całość zapisu tego uczestnika — nawet jeśli zapisujesz go na kilka terminów.
- Na liście uczestników pojawia się przy nim żółta plakietka „czeka na regulamin". Miejsce jest w tym czasie zajęte i nikt inny go nie zajmie.
- Gdy klient kliknie link i zaakceptuje regulamin, plakietka znika. Zapis jest potwierdzony.
- Jeśli klient nie zaakceptuje w wyznaczonym czasie (domyślnie 30 minut), system wypisuje go z tych zajęć i wysyła mu SMS z wyjaśnieniem. W historii zajęć zostaje wpis „nie zaakceptował regulaminu w wyznaczonym czasie".
- Jeśli klient zdążył już zapłacić, pieniądze wracają na jego portfel — również gotówka wpłacona w recepcji i płatność kartą. Nie musisz tego księgować ręcznie. Klient widzi zwrot w historii portfela z opisem „Zwrot za anulowany zapis", a Ty w historii zajęć obok wypisania.
- Wyjątek: płatność podzielona między kilka osób. Tej system nie zwraca sam — nie wie, komu ile oddać, a przy racie odroczonej część pieniędzy w ogóle nie wpłynęła. Miejsce zwalnia się normalnie, ale zwrot trzeba rozdzielić ręcznie z ekranu płatności.
Co zrobić, gdy klient płaci przy kasie
Zapłata nie zastępuje akceptacji regulaminu — to dwie różne rzeczy i sama wpłata nie utrzyma miejsca. Jeśli klient stoi przy kasie i płaci, dopilnuj, żeby od razu kliknął link z SMS-a; inaczej za kwadrans system zdejmie go z zajęć, a pieniądze odłoży na jego portfel. Zapisując go ponownie, pokryjesz zapis z tego portfela — kwota nie przepada, ale robicie tę samą rzecz dwa razy.
Kiedy system nie pozwoli zapisać
Zapis zostanie odrzucony w całości, zanim cokolwiek się zmieni w kalendarzu, w dwóch przypadkach:
| Komunikat | Co zrobić |
|---|---|
| Uczestnik nie ma numeru telefonu | Uzupełnij numer w profilu klienta. Bez numeru nie ma jak wysłać linku, więc klient zostałby wypisany, nie wiedząc dlaczego. |
| Uczestnik nie ma daty urodzenia | Uzupełnij datę w profilu. Od wieku zależy, który regulamin obowiązuje — poniżej 18 lat regulamin szkółki, od 18 lat regulamin dla dorosłych i seniorów. Nie dotyczy zajęć wakacyjnych ani indywidualnych, które mają po jednym regulaminie dla wszystkich. |
Jeśli SMS nie uda się wysłać (np. awaria bramki), zapis zostaje cofnięty, a Ty dostajesz komunikat. Nie zostaje „wiszący" zapis, którego klient nie może potwierdzić.
Na jaki numer idzie SMS
Na numer uczestnika, jeżeli jest w jego profilu. Jeżeli go nie ma — na numer właściciela konta (opiekuna). W praktyce oznacza to, że SMS w sprawie dziecka trafia do rodzica.
Zmiana terminu przed akceptacją
Dopóki klient nie zaakceptuje regulaminu, uczestnika nie da się przenieść do innej grupy („Zmień termin zajęć” w grafiku ani „Przenieś” w kalendarzu klienta). Okno przeniesienia pokaże komunikat o niezaakceptowanym regulaminie. Po akceptacji zmiana terminu działa normalnie; po wygaśnięciu okna zapis znika i uczestnika trzeba zapisać od nowa. Szczegóły: Przeniesienie cyklu zajęć.
Co, jeśli cofniesz zapis w międzyczasie
Usunięcie uczestnika z zajęć przed upływem terminu zamyka też oczekiwanie na regulamin. Klient nie dostanie SMS-a o anulowaniu za kwadrans.
Jakich zajęć dotyczy
Wszystkich zajęć grupowych, zajęć typu „inne" (Klub Seniora i każda kolejna kategoria, którą administrator doda pod tym typem), zajęć wakacyjnych oraz zajęć indywidualnych.
Który regulamin dostanie klient, zależy od rodzaju zajęć:
| Zajęcia | Regulamin w SMS-ie | Data urodzenia |
|---|---|---|
| Grupowe i „inne" — uczestnik do 18 lat | regulamin szkółki tenisowej | wymagana |
| Grupowe i „inne" — uczestnik od 18 lat | regulamin dla dorosłych i seniorów | wymagana |
| Wakacyjne | regulamin wakacyjnej szkółki | niewymagana |
| Indywidualne | regulamin dla dorosłych, Klubu Seniora i zajęć indywidualnych | niewymagana |
Regulamin wakacyjny jest jeden dla dzieci i dorosłych, a regulamin dla dorosłych, Klubu Seniora i zajęć indywidualnych obejmuje każdy wiek, na jaki sprzedaje się zajęcia indywidualne. W obu tych programach data urodzenia nie jest potrzebna i jej brak nie zablokuje zapisu. Numer telefonu jest wymagany zawsze — bez niego nie ma jak wysłać linku.
SMS nazywa rodzaj zajęć: „zapis na zajecia stale", „zapis na zajecia wakacyjne" albo „zapis na zajecia indywidualne".
Zapis wakacyjny z kalendarza a kreator zapisu wakacyjnego
To dwie różne drogi i nie zbierają zgody dwa razy:
- Kalendarz → edycja zajęć → lista uczestników — droga opisana w tym dokumencie. Zgoda przychodzi SMS-em po zapisie.
- Profil uczestnika → kreator zapisu wakacyjnego — checkbox z regulaminem jest w samym kreatorze, zapis bez niego nie przechodzi. SMS nie jest wysyłany.
Czego funkcja nie obejmuje
- obozów i kursów tenisowych — mają własne dokumenty wgrywane per sezon,
- rezerwacji kortu i odrabiania zajęć,
- zajęć indywidualnych umawianych przez wniosek klienta lub kreator w profilu — tam zgoda jest checkboxem w samym oknie umawiania, więc SMS nie dubluje jej,
- zakładania zajęć grupowych i wakacyjnych z gotową listą uczestników — tam procedura obejmuje dopisywanie uczestników do zajęć, które już istnieją. Wyjątkiem są zajęcia indywidualne, opisane wyżej,
- transferu między grupami — przeniesiony klient zachowuje wcześniejszą zgodę i nie dostaje nowego SMS-a,
- zapisów, które klient robi sam przez panel — te zbierają zgodę w formularzu.
Ustawienia (panel administratora)
Każdy program ma własną parę ustawień, osobno dla każdego miasta:
- Zajęcia stałe → Akceptacja regulaminu — dotyczy zajęć grupowych i typu „inne" (Klub Seniora itd.),
- Zajęcia wakacyjne → Akceptacja regulaminu — dotyczy wyłącznie zajęć wakacyjnych. Domyślnie wyłączone — trzeba je włączyć w tym miejscu, osobno dla każdego miasta,
- Zajęcia indywidualne → Akceptacja regulaminu — dotyczy wyłącznie zajęć indywidualnych. Domyślnie wyłączone, tak samo jak wakacyjne.
We wszystkich trzech sekcjach te same dwa pola:
- Wymagaj akceptacji regulaminu po zapisie przez recepcję — wyłącznik całej procedury dla tego programu,
- Czas na akceptację regulaminu (minuty) — domyślnie 30. Puste pole znaczy „bez limitu", co w praktyce wyłącza wypisywanie.
Programy są od siebie niezależne: wyłączenie procedury dla jednego nie rusza dwóch pozostałych.
Część 2. Dokumentacja techniczna
Przepływ
Model danych
statute_acceptance_request (migracja 0231) — stan okna akceptacji, nie
sama zgoda. Zgoda trafia do consent_log, który pozostaje źródłem prawdy dla
RODO art. 7(1).
Kluczowe kolumny:
| Kolumna | Znaczenie |
|---|---|
token | 10 znaków z alfabetu 31-symbolowego bez znaków mylonych na ekranie telefonu. Krótki, żeby cały link zmieścił się w jednym segmencie SMS. |
game_ids | JSON z dokładnie tymi zajęciami, które dodał ten zapis. To jest lista, którą cofa wygaszenie — nic, co uczestnik miał wcześniej. |
document_key, document_version | Regulamin rozstrzygnięty w chwili zapisu, żeby urodziny między SMS-em a kliknięciem nie podmieniły umowy. |
programme | class | vacation | individual (migracja 0267). Rozstrzyga typ zgody i brzmienie SMS-a tam, gdzie dokument już nie wystarcza. |
status | pending → accepted | expired | cancelled. |
expires_at | Termin; accepted_at i resolved_at domykają wiersz. |
Tabela celowo bez kluczy obcych — tak samo jak consent_log: żądanie przeżywa
zajęcia, na które wskazuje.
Które typy aktywności są objęte
resolveStatuteProgramme(activityTypeKind, activityTypeCategory) w
lib/statute-acceptance.ts — jedyna bramka procedury, pytana przez
isStatuteAcceptanceEnabled() przed odczytem ustawień. Zwraca program, a nie
tylko tak/nie, bo od programu zależy dokument:
activity_types.type | activity_types.category | Program | Dokument |
|---|---|---|---|
group | inna niż Wakacyjne | class | wg wieku uczestnika |
other | inna niż Wakacyjne | class | wg wieku uczestnika |
group lub other | Wakacyjne | vacation | vacation_statute, bez podziału wieku |
individual | dowolna | individual | adult_individual_statute, bez podziału wieku |
booking | — | null | procedura nie dotyczy |
Kategoria jest polem swobodnym, dlatego program class jest domyślny przez
wykluczenie: nowa kategoria pod typem other (jak Klub seniora) jest objęta
od razu, bez zmian w kodzie. Rozpoznawana jawnie jest tylko Wakacyjne
(VACATION_ACTIVITY_CATEGORY z constants/data.ts).
Kategoria zajęć indywidualnych nie ma znaczenia: regulamin dla dorosłych, Klubu
Seniora i zajęć indywidualnych obejmuje każdy wiek, więc individual wygrywa
przed rozpoznaniem Wakacyjne.
Program idzie do loadStatuteCandidates(), gdzie mapa
STATUTE_PROGRAMME_DOCUMENTS przypisuje programowi jego jedyny dokument —
vacation_statute dla wakacyjnych, adult_individual_statute dla
indywidualnych — bez patrzenia na datę urodzenia. Tylko class ma w tej
mapie null i rozstrzyga dokument wiekiem. Stąd brak blokady
missing_date_of_birth w dwóch pozostałych programach. findStatuteBlocks()
zostaje bez zmian: kandydat z ustawionym dokumentem przechodzi test wieku, a
blokada braku telefonu obowiązuje dalej.
Typ i kategoria idą przez assertStatuteAcceptancePossible(),
requestStatuteAcceptanceForSeats() i requestStatuteAcceptance();
getGameById() czyta kategorię jako activity_type.category.
Zgoda zapisywana po akceptacji
statuteConsentTypeForProgramme(programme) odczytuje typ zgody z programu
zapisanego w żądaniu, a nie z bieżącej konfiguracji zajęć — zajęcia mogą się
zmienić między SMS-em a kliknięciem:
programme | consent_log.consent_type |
|---|---|
vacation | vacation_enrollment |
class | school_enrollment |
individual | individual_training_statute |
Wcześniej typ zgody wynikał z document_key. To przestało wystarczać, gdy
doszły zajęcia indywidualne: dzielą adult_individual_statute z każdym
dorosłym zapisanym na zajęcia stałe, więc sam dokument nie mówi już, który to
kontrakt. Kolumna programme (migracja 0267) rozstrzyga to jednoznacznie;
wiersze sprzed migracji dostają class, a wakacyjne backfill po dokumencie.
Ten sam mapping obsługuje wpis consent_accepted w event_log zajęć, więc
historia zajęć i profil klienta pokazują każdą zgodę pod jej własną nazwą.
Obok zawsze idzie druga zgoda: privacy_policy.
Strona /regulamin/[token] jest w całości sterowana document_key — link do
dokumentu bierze się z CONSENT_DOCUMENTS[documentKey], a jego nazwę w tekście
checkboxa wybiera mapa STATUTE_LINK_LABEL_KEYS w
statute-acceptance-form.tsx (klucz nieznany → nazwa regulaminu szkółki).
Wyłącznik osobny dla każdego programu
Program decyduje, z którego ekranu ustawień czytane są wyłącznik i okno:
| Program | Ekran | Klucze |
|---|---|---|
class | Zajęcia stałe | group_statute_acceptance_enabled, group_statute_acceptance_time |
vacation | Zajęcia wakacyjne | vacation_statute_acceptance_enabled, vacation_statute_acceptance_time |
individual | Zajęcia indywidualne | individual_statute_acceptance_enabled, individual_statute_acceptance_time |
Puste pole i brak wiersza to dwie różne odpowiedzi. '' znaczy „bez limitu" —
nic nie wygasa, więc okna w ogóle się nie otwiera; brak klucza to miasto, którego
nikt jeszcze nie ustawiał, i dopiero ono dostaje domyślne 30 minut. Zapis
przycina wartość do 1–1440 minut, żeby ręczna edycja w surowych ustawieniach nie
zostawiła okna zerowego ani wielodniowego.
loadStatuteConfig() sprowadza wszystkie trzy pary do wspólnego kształtu
StatuteAcceptanceConfig { enabled, minutes }, więc reszta modułu nie musi
wiedzieć, z którego ekranu przyszła odpowiedź.
resolveStatuteAcceptanceDeadline() przyjmuje dziś minuty, a nie obiekt
ustawień — reguła zamiany okna na termin jest wspólna dla wszystkich programów.
Wyłączenie jednego programu nie rusza pozostałych.
Domyślne stany różnią się między programami i jest to celowe:
| Program | Po wdrożeniu | Brak wiersza w app_settings |
|---|---|---|
class | włączony | traktowany jako włączony |
vacation | wyłączony | traktowany jako wyłączony |
individual | wyłączony | traktowany jako wyłączony |
Migracja 0258 zasiewa parę wakacyjną dla każdego miasta i najemcy mającego już
jakiekolwiek ustawienia, ale z wyłącznikiem na 0 — klub włącza procedurę
miasto po mieście, kiedy recepcja jest o niej uprzedzona. Okno jest zasiane mimo
to, żeby pole miało sensowną wartość w chwili włączenia.
Migracja 0265 podnosi oba okna z 15 na 30 minut wszędzie tam, gdzie wciąż stoi
zasiana wartość 15. Powód: 15 minut było krótsze niż sama płatność — klient
wchodził w link, płacił w Przelewy24 i wracał po terminie. Okno ustawione ręcznie
przez administratora migracja zostawia nietknięte.
Odczyt bez wiersza też zwraca „wyłączone", więc nowe miasto albo nowy najemca nie uruchomi procedury sam z siebie. To jedyne miejsce, w którym wakacyjne zachowują się inaczej niż zajęcia stałe, gdzie brak wiersza znaczy „włączone".
Współistnienie z oknem płatności wakacyjnej
Zajęcia wakacyjne z włączoną natychmiastową płatnością pracowniczą oznaczają
nowego uczestnika jako draft z paymentExpiresAt. Akceptacja regulaminu
dokłada obok tego statuteExpiresAt — markAttendeesPendingStatute() kopiuje
pozostałe pola wpisu, więc oba okna biegną niezależnie i wygaśnięcie
któregokolwiek zwalnia miejsce.
Blokada miejsca
Uczestnik oczekujący na akceptację zostaje na liście game.attendees i ma
ustawione pole statuteExpiresAt. To świadomie różni się od draft
(używanego przy wstrzymanych płatnościach), który bywa odfiltrowywany z
liczników: tutaj miejsce ma być naprawdę zarezerwowane przez całe okno.
Pole czyta kalendarz (plakietka w AttendeeInfo) i cron.
Wygaszenie usuwa uczestnika z tablicy — jak
removePlayerFromRecurringSeries — a ślad zostaje w event_log
(statute_expired). Usuwane są wyłącznie wpisy z ustawioną flagą, więc
równoległy zapis innym kanałem nie zostanie skasowany.
Wyznaczanie terminu
resolveStatuteAcceptanceDeadline() bierze group_statute_acceptance_time
minut od chwili zapisu i skraca termin do startu pierwszych zajęć, jeżeli
te zaczynają się wcześniej. Wartość pusta = brak terminu = procedura nie
tworzy żądania.
Cron i granulacja
Job expire_statute_requests chodzi w wyzwalaczu */5 * * * * obok
expire_payments, oba przez runIndependentJobs, żeby awaria jednego nie
blokowała drugiego. Faktyczne wypisanie następuje 30–35 minut po zapisie —
cron ma rozdzielczość 5 minut. Strona akceptacji sprawdza jednak expires_at,
a nie moment przebiegu crona, więc klient, który kliknie w tej luce, i tak
dostanie odmowę. Limit MAX_EXPIRIES_PER_RUN = 100 chroni budżet czasu workera
przy zaległościach.
SMS
Wysyłane bezpośrednio przez sendSms z pominięciem sendNotificationToUser.
Powód: to jedyna droga do utrzymania zapisu, a klient, który kiedyś wyłączył
sobie powiadomienia SMS, zostałby po cichu wypisany z zajęć. Z tego samego
powodu treści są stałymi w kodzie, a nie wierszami notification_texts —
szablon, który straciłby {url}, potrafiłby wypisać wszystkich zapisanych.
Teksty są bez polskich znaków diakrytycznych: jedno „ą" przełącza wiadomość na UCS-2 i tnie segment ze 160 do 70 znaków.
Zwykłe powiadomienie player_added_to_recurring_series / player_added_to_game
jest dla tych uczestników pomijane, żeby klient dostał jeden SMS o zapisie,
a nie dwa sprzeczne.
Płatności
Zasada: uczestnik zdjęty z zajęć trzyma albo miejsce, albo pieniądze — nigdy ani jednego, ani drugiego. Zapłata nie jest akceptacją regulaminu, więc miejsce leci tak czy inaczej, ale rozliczenie idzie razem z nim.
Wygaszenie bierze płatności powiązane z tymi zajęciami
(json_extract(related_ids,'$[0]')) i tym uczestnikiem, po czym:
| Status płatności | Co się dzieje |
|---|---|
pending | cancelled — a to, co klient zdążył dołożyć z portfela, wraca na portfel przed anulowaniem |
paid, paid_cash, paid_card, paid_online, paid_wallet, paid_partial_wallet, paid_mixed | pełna kwota na portfel klienta, refunded + funds_retained = 1 |
paid_split, paid_split_partial | nie ruszane automatycznie — trafia do logów jako sprawa do ręcznego podziału |
| cokolwiek innego | zgłoszone jako SEAT_REFUND_UNKNOWN_STATUS, nigdy po cichu pominięte |
Zwrot idzie na portfel niezależnie od pierwotnej metody płatności — także
za gotówkę wpłaconą w recepcji. Klub zatrzymuje wpłatę, klient zatrzymuje jej
wartość; to ta sama zasada, którą stosuje wypisanie przez pracownika
(refundPaymentsByRelatedIdForEmployeeRemoval).
Płatność podzielona nie jest zwracana automatycznie. createSplitPayment
rozkłada jedną płatność na kilka wierszy linked_payment, które mogą wskazywać
różnych płatników, a przy paid_split_partial część rat jest odroczona i klub
nigdy ich nie pobrał. Uznanie całej kwoty nadrzędnej portfelowi właściciela
miejsca zapłaciłoby więc niewłaściwej osobie i oddało pieniądze, których nikt
nie wpłacił. Dlatego te dwa statusy idą do recepcji jako
Released seat was paid by a split payment; the refund has to be divided by hand.
Wypisanie przez pracownika pomija je z tego samego powodu.
Częściowa płatność portfelem zostaje przy pending.
payPartialWithWallet obciąża portfel i zostawia płatność w pending z
obniżoną kwotą. Samo anulowanie takiego wiersza oddałoby klubowi pieniądze z
portfela za zajęcia, których klient już nie ma — więc uznanie idzie przed
anulowaniem: przebieg, który padnie pomiędzy, naprawi się w kolejnym, bo wiersz
wciąż jest pending, a strażnik idempotencji nie pozwoli uznać drugi raz.
Wpis w portfelu dostaje gotowy polski opis (Zwrot za anulowany zapis - <nazwa zajęć> (regulamin nie został zaakceptowany)), a nie klucz i18n: cron nie ma
żądania, w którym mógłby go rozwinąć, a opis widzi klient w historii portfela.
Na event_log zajęć ląduje wpis refund_to_wallet obok statute_expired.
Czego zwrot nie ruszy: płatności bez właściciela, bez kwoty albo takiej, której
uznanie portfela się nie powiodło. Wtedy status wraca do poprzedniego (nigdy
refunded bez pokrycia w portfelu), a sprawa idzie do logów jako
Settled payments could not be refunded after a released sign-up.
Podwójne uznanie jest odcięte przez księgę portfela, nie przez status
płatności: przed zwrotem sprawdzany jest istniejący wpis credit z tym
related_payment_id i tym samym opisem, więc powtórny przebieg crona
niczego nie dopłaca. Sam related_payment_id nie wystarcza — obniżenie ceny
zajęć (planTotalPriceChange → commitPriceChangePlans) zwraca różnicę na portfel z tym samym
identyfikatorem i zostawia płatność rozliczoną. Klucz oparty wyłącznie na
płatności czytał taki wpis jako „już zwrócone" i oznaczał miejsce jako
refunded, nie wysyłając ani złotówki — w dodatku raportując to jako sukces.
Rollback po nieudanym SMS-ie
releaseStatuteRequest(..., 'cancelled') to nie wygaśnięcie okna, tylko
wycofanie zapisu, którego nie dało się potwierdzić SMS-em — recepcjonista wciąż
stoi przy ladzie i za chwilę spróbuje ponownie. Dlatego ta ścieżka nie zwraca
rozliczonych płatności: oddanie na portfel gotówki wziętej sekundę wcześniej
kazałoby klubowi rozliczyć jeden zapis dwa razy. pending jest anulowane (wraz
ze zwrotem tego, co poszło z portfela), a rozliczone płatności trafiają do logów
do decyzji recepcji. Automatyczny zwrot ma tylko expired.
Kiedy nieudany SMS dotyczy zapisu, który dopiero co utworzył zajęcia —
lekcję indywidualną albo całą serię — cofany jest również sam wpis w kalendarzu
(rollbackCreatedGame / rollbackCreatedGames). Bez tego kort zostawałby zajęty
przez lekcję bez uczestników, a recepcja przy ponownej próbie zakładała drugą.
requestStatuteAcceptanceForSeats zamyka przy okazji okna otwarte wcześniej w
tym samym zapisie: rodzeństwo, do którego SMS już poszedł, nie może trzymać
miejsca na zapisie, który właśnie jest wycofywany.
:::warning Historia
Do 09/2026 wygaszenie zwalniało miejsce, ale rozliczonej płatności nie
ruszało — zostawała jako opłacona bez miejsca, a ponowny zapis wystawiał tę
samą kwotę drugi raz. Ostrzeżenie, które miało to wyłapywać, nigdy nie
docierało do tabeli logs (patrz „Logi crona" niżej).
:::
Logi crona
logInfo/logWarn/logError z modułów bibliotecznych piszą przez wspólny
singleton loggera, który bazę bierze z getRuntimeEnv(). W przebiegu
scheduled nie ma żądania, więc każdy taki wpis był po cichu porzucany —
job miał własny new Logger(env.DB), ale wszystko, co wywoływał, logowało w
próżnię. Dlatego ostrzeżenia o pieniądzach bez miejsca nie widział nikt.
handleScheduled opakowuje teraz przebieg w withLoggerDatabase(env.DB, …):
na czas joba singleton dostaje uchwyt do bazy, a na końcu czeka na rozpoczęte
zapisy (logger.flush()) i uchwyt oddaje. Podpinany jest wyłącznie uchwyt,
nigdy tenantId — dzięki temu wspólny isolate nie przeniesie tożsamości
jednego klubu na żądanie innego.
Zgoda w consent_log
Zapisywana w route handlerze (app/api/public/statute-acceptance/route.ts), bo
tylko tam istnieje kontekst żądania z adresem IP i user agentem klienta.
Zapisywane są dwa wiersze: school_enrollment (z dokumentem rozstrzygniętym
wg wieku) i privacy_policy, ze źródłem reception_link — nowa wartość
ConsentSource, odróżniająca zgodę daną przez klienta na własnym telefonie od
zapisu wykonanego przez pracownika (employee).
Grupowanie terminów na stronie
Strona pokazuje jeden wiersz na slot, nie na pojedyncze zajęcia: zapis recepcyjny obejmuje wszystkie przyszłe terminy serii, a klient patrzący na trzydzieści identycznych linii nie widzi, na co się zgadza. Licznik „18 terminów" niesie resztę informacji.
Kluczem grupy jest nazwa aktywności, kort oraz dzień tygodnia i godzina
liczone w czasie polskim (formatInTimeZone(..., POLAND_TIMEZONE, 'i-HH:mm')).
game.start_time trzymamy w UTC, więc sobotnie zajęcia o 10:00 to 08:00Z
latem i 09:00Z zimą — klucz zbudowany z surowego UTC rozbijał jedną serię na
dwie karty przy zmianie czasu, obie wyświetlane jako „10:00 – 11:00", bo widok
konwertuje z powrotem na Europe/Warsaw.
Pliki
| Plik | Rola |
|---|---|
migrations/0231_create_statute_acceptance_request.sql | tabela, ustawienia, wiersz crona |
migrations/0267_statute_request_programme.sql | kolumna programme + backfill wakacyjnych |
lib/statute-acceptance.ts | rdzeń: guard, token, żądanie, akceptacja, wygaszanie. Bez importów Next i bez getCloudflareContext, bo ładuje go worker crona |
lib/statute-acceptance-view.ts | dane publicznej strony |
app/regulamin/[token]/ | strona akceptacji z odliczaniem |
app/api/public/statute-acceptance/route.ts | POST akceptacji + consent_log |
lib/actions/game.ts | wpięcie w updateGame i updateRecurringGames, a dla zajęć indywidualnych także w createGame i generateRecurringGames |
worker-crons.ts | rejestracja expire_statute_requests |
middleware.ts | /regulamin i /api/public/statute-acceptance jako trasy publiczne |
app/layout.tsx | statuteAcceptance w PUBLIC_MESSAGE_NAMESPACES — bez tego komponenty klienckie renderują surowe klucze |
scripts/route-variants.json | /regulamin przypisane do wariantu CLIENT, inaczej build klienta wycina trasę |
lib/seat-payment-refund.ts | rdzeń rozliczenia zwalnianych miejsc: anulowanie pending, zwrot rozliczonych na portfel. Przyjmuje D1Database od wołającego, więc działa i w cronie, i w żądaniu |
lib/logger.ts | withLoggerDatabase — podpięcie bazy dla przebiegów poza żądaniem |
__tests__/lib/statute-acceptance.test.ts | 23 testy na SQLite w pamięci |
__tests__/lib/statute-acceptance-view.test.ts | 4 testy grupowania terminów, w tym seria przechodząca przez zmianę czasu |
__tests__/lib/seat-payment-refund.test.ts | 17 testów rdzenia zwrotu (rollback, idempotencja, izolacja tenantów, płatności dzielone, portfel przy pending) |
__tests__/lib/logger-cron-binding.test.ts | 5 testów podpięcia i domknięcia logów crona |
Idempotencja i wyścigi
Akceptacja opiera się na warunkowym UPDATE ... WHERE status = 'pending' AND expires_at > ?. Podwójne kliknięcie albo dwie karty naraz kończą się jednym
przejściem; przegrane wywołanie dostaje already_accepted lub expired.
Wpis consent_accepted w event_log trafia tylko na najwcześniejsze zajęcia z
żądania, żeby zgoda nie powtarzała się na trzydziestu wystąpieniach serii.