Rejestr zgód (consent_log)
Rejestr wszystkich zgód, jakich klient udziela i jakie wycofuje — regulaminów, RODO, umów obozowych i zgód marketingowych. Służy do wykazania przed klientem lub organem nadzorczym, że zgoda faktycznie została wyrażona, kiedy i przez kogo.
👤 Instrukcja dla pracownika
Po co to jest
RODO (art. 7 ust. 1) wymaga, by administrator danych umiał wykazać, że klient wyraził zgodę. Nie wystarczy, że system pokazuje „zgoda: tak" — trzeba pokazać moment jej udzielenia i to, na co konkretnie klient się zgodził. Rejestr zgód odpowiada na pytania:
- Czy ten klient zgodził się na wysyłkę maili marketingowych? Kiedy?
- Czy klient zaakceptował regulamin przy tej rezerwacji?
- Który dokument (wersja regulaminu) klient widział w momencie akceptacji?
- Czy zgodę wycofał — a jeśli tak, to kiedy i w jaki sposób?
- Czy zgodę odznaczył sam klient, czy zrobił to pracownik w jego imieniu?
Gdzie to znaleźć
Historia (Dashboard ➔ Historia) pokazuje zgody powiązane z konkretną aktywnością — rezerwacją kortu, obozem, kursem tenisowym lub zajęciami — razem z pozostałymi zdarzeniami tej aktywności. Wpis nazywa konkretny dokument, np. „zaakceptował/a klauzulę RODO obozu", a nie ogólnie „regulamin".
Karta klienta (Dashboard ➔ Klienci ➔ [klient] ➔ sekcja „Zgody i regulaminy") pokazuje pełną historię zgód danej osoby w jednym miejscu, od najnowszej. Przy większej liczbie wpisów lista przewija się we własnym oknie, żeby nie rozpychać karty klienta. Przy każdym wpisie widać:
- czy zgoda została udzielona czy wycofana,
- datę i godzinę udzielenia zgody (czas polski),
- kanał (online, pracownik, link płatniczy, rejestracja, link z recepcji),
- wersję dokumentu — etykieta „Wersja dok. …" i odnośnik do jego treści, jeśli są dostępne,
- którego uczestnika zgoda dotyczy,
- jakich zajęć dotyczy: nazwa zajęć, termin i miejsce (przy obozach i kursach — nazwa turnusu i jego zakres dat),
- adres IP z chwili akceptacji,
- wyraźne ostrzeżenie, jeśli zmiany dokonał pracownik, wraz z jego adresem e-mail.
To także jedyne miejsce, w którym widać zgody marketingowe — nie dotyczą one żadnych konkretnych zajęć, więc w Historii się nie pojawiają.
Dwie daty przy jednym wpisie — czym się różnią
To najczęstsze nieporozumienie przy czytaniu rejestru:
| Co widać | Co oznacza |
|---|---|
05/09/2026 14:34 (szara, pod nazwą) | kiedy klient udzielił zgody — moment kliknięcia, stemplowany przez serwer |
Wersja dok. 2026-08-18 (etykieta) | którą wersję dokumentu wtedy zaakceptował — znacznik wersji pliku, a nie data zgody |
Wersja jest potrzebna, bo regulaminy leżą na stronie pod stałymi adresami i mogą zostać podmienione — sam odnośnik nie dowodzi, na co klient się zgodził. Nad listą znajduje się krótka legenda powtarzająca to rozróżnienie.
Wersja to zawsze pełna data publikacji pliku (RRRR-MM-DD). Wcześniej część dokumentów miała znacznik z samym miesiącem (2026-08) albo v1, przez co w jednej liście sąsiadowały ze sobą trzy różne formaty. Starsze wpisy zachowują znacznik, jaki miały w chwili akceptacji — rejestr jest dowodowy i nie jest przepisywany wstecz, więc na kartach klientów jeszcze przez jakiś czas będą widoczne stare, krótsze wersje. Nie jest to błąd: taki znacznik dotyczy najczęściej starszego pliku, który faktycznie był wtedy pokazany.
O czym trzeba pamiętać
- Zmiana zgody przez pracownika jest odnotowywana imiennie. Przestawienie przełącznika w
Dashboard ➔ Mailing ➔ Zgodyzapisuje w rejestrze e-mail pracownika, który tego dokonał. Jest to działanie w pełni dopuszczalne (np. na prośbę klienta zgłoszoną w recepcji), ale pozostawia trwały ślad. - Wycofanie zgody nie kasuje jej udzielenia. Rejestr wyłącznie dopisuje kolejne wpisy, nigdy nie nadpisuje wcześniejszych. Historia jest pełna i nieusuwalna.
- Rejestr nie znika razem z rekordem. Usunięcie zajęć czy rejestracji na obóz nie kasuje dowodu zgody.
- Rejestr zgód to nie to samo co Logi systemowe. Czyszczenie logów w
Dashboard ➔ Loginie ma wpływu na rejestr zgód.
Zgoda wizerunkowa a zgoda marketingowa (zmiana, o której trzeba wiedzieć)
Do czasu migracji 0209 przełącznik „Zgoda wizerunkowa" w profilu klienta zapisywał się do tej samej kolumny w bazie, co zgoda na komunikację elektroniczną. Ponieważ odbiorców kampanii wybierano między innymi po tej kolumnie, klient, który zgodził się wyłącznie na wykorzystanie wizerunku, był kwalifikowany jako odbiorca maili marketingowych.
Co się zmieniło:
- Zgoda wizerunkowa ma własne pole i własny wpis w rejestrze (
image_rights). Jest teraz niezależna od zgód marketingowych. - Kampanie trafiają wyłącznie do klientów z zaznaczoną „Zgodą marketingową". Sama zgoda na komunikację elektroniczną już nikogo nie kwalifikuje.
- Dotychczasowe wartości w starej kolumnie były mieszaniną obu znaczeń i nie da się ich rozdzielić — dlatego nie uprawniają do niczego.
Praktyczny skutek dla obsługi: część klientów, którzy wcześniej otrzymywali kampanie, przestanie je otrzymywać, dopóki nie zaznaczą „Zgody marketingowej". Jest to działanie celowe — bezpieczniej nie wysłać maila do osoby, co do której nie mamy pewności, niż wysłać go bez ważnej podstawy. Liczbę takich osób sprawdzisz zapytaniem podanym w części technicznej.
🛠 Dokumentacja techniczna
Model danych
Tabela consent_log (migracja 0208_create_consent_log.sql) jest append-only i celowo nie ma kluczy obcych — context_id to luźne odniesienie, dzięki czemu usunięcie gry lub rejestracji nie kaskaduje na dowód zgody. Tożsamość podmiotu i osoby akceptującej jest zdenormalizowana z tego samego powodu.
| Kolumna | Znaczenie |
|---|---|
consent_type | rodzaj zgody, np. booking_statute, camp_rodo, marketing_electronic |
granted | 1 = udzielenie, 0 = wycofanie |
subject_email, subject_player_id | kogo zgoda dotyczy |
document_url, document_version | który dokument został zaakceptowany |
source | online, employee, payment_link, registration, api, backfill |
actor_email, actor_type | kto dokonał akceptacji (customer / employee / system) |
ip_address, user_agent | odcisk żądania z chwili akceptacji |
context_type, context_id | powiązanie z grą, rezerwacją lub rejestracją |
accepted_at | znacznik czasu zawsze stemplowany po stronie serwera |
Kolumny *_accepted_at na tabelach booking, camp_registration i tennis_course_registration pozostają na miejscu jako tania ścieżka odczytu dla interfejsu. Rejestrem dowodowym jest consent_log.
Zapis
Wszystkie punkty akceptacji przechodzą przez jeden helper — recordConsent() w lib/consent-log.ts. Helper:
- stempluje
accepted_atczasem serwera, - wyciąga IP i user-agenta z nagłówków żądania (
cf-connecting-ip, z fallbackiem nax-forwarded-for/x-real-ip), - normalizuje adresy e-mail do małych liter,
- działa w trybie best-effort — nigdy nie rzuca wyjątkiem.
Ostatni punkt jest świadomą decyzją: zgoda zapisywana jest w drodze do bramki płatniczej albo w trakcie rejestracji, a przerwanie płatności z powodu wiersza audytowego byłoby gorszym skutkiem niż jego brak. Błędy trafiają do logError.
Znacznik czasu pochodzi z serwera, nie od klienta. Publiczne strony rezerwacji i płatności wysyłały wcześniej własne
new Date().toISOString(), które użytkownik może ustawić dowolnie — jako dowód była to wartość bezużyteczna. Strony nadal muszą wykazać, że pole zostało zaznaczone; nie decydują już, kiedy.
Objęte ścieżki akceptacji
| Ścieżka | Miejsce w kodzie | consent_type |
|---|---|---|
| Opłacenie linku płatniczego rezerwacji | app/api/public/payment/initialize | booking_statute |
| Płatność za rezerwację w aplikacji | lib/actions/users.ts | booking_statute |
| Obóz — regulamin, RODO, umowa | lib/actions/camp-payment-link.ts | camp_statute, camp_rodo, camp_contract |
| Kurs tenisowy — regulamin, RODO | lib/actions/tennis-course-payment-link.ts | tennis_course_statute, tennis_course_rodo |
| Zapis na serię stałą | lib/enrollment-consent.ts | school_enrollment |
| Zapis na zajęcia wakacyjne | lib/actions/game.ts | vacation_enrollment |
| Płatność za zajęcia wakacyjne | lib/actions/payment.ts | vacation_payment |
| Zgody marketingowe (profil i zmiana hurtowa) | lib/actions/users-db.ts | marketing_general, marketing_electronic |
| Zgoda wizerunkowa (profil) | lib/actions/users-db.ts | image_rights |
| Wypisanie z maili | lib/actions/mail-campaigns.ts | marketing_electronic (wycofanie) |
Formularz publicznej rezerwacji nie zbiera już akceptacji — nazywa oba dokumenty (regulamin i politykę prywatności) i linkuje je przy danych kontaktowych, a zgoda jest brana raz, na stronie płatności (/pay/[token], checkbox blokujący przycisk „Zawieram umowę i płacę"). Wcześniej klient akceptował regulamin dwa razy w jednym przejściu i powstawały dwa wpisy w consent_log.
Zgody marketingowe zapisywane są w updateUserInDb(), które przed zapisem odczytuje poprzednią wartość i tworzy wpis wyłącznie wtedy, gdy wartość faktycznie się zmienia — zwykły zapis profilu nie może wytworzyć fałszywego dowodu udzielenia zgody.
Podmiot zgody a osoba akceptująca
subject_email i actor_email to dwie różne rzeczy i nie wolno ich zlewać:
subject_email— klient, którego zgoda dotyczy i który jest nią związany. Przy zapisie uczestnika zawszeplayer.owner_email, niezależnie od tego, kto klikał.actor_email+actor_type+source— kto fizycznie zaznaczył kwadracik. Klient sam (online/customer) albo pracownik w jego imieniu (employee/employee).
Zapis na serię stałą (recordSeriesStatuteConsent) przyjmuje oba osobno, bo kreator „Zapisz się na szkółkę" jest dostępny także dla personelu — loadContext w lib/regular-enrollment.ts przepuszcza pracownika do dowolnego uczestnika, bo recepcja prowadzi ten sam zapis przez telefon. Wcześniej funkcja miała source i actor_type zaszyte na online / customer, więc zgoda odklikana przy ladzie zapisywała się jako akceptacja klienta online.
Zgoda przez SMS-owy link po zapisie recepcyjnym ma własne źródło reception_link — to klient klika ją sam, więc actor_type pozostaje customer mimo że zapis zainicjowała recepcja. Opisuje to Akceptacja regulaminu po zapisie przez recepcję.
Jak zgody wyglądają w Historii aktywności
Dokumenty zaznaczone jednym kliknięciem (regulamin razem z RODO albo z polityką prywatności) to jeden akt, więc w Historii aktywności tworzą jeden wpis, nie po jednym na dokument. Wpis jest opisany nazwą regulaminu, a pozostałe zaakceptowane dokumenty wymienia wiersz „Dokumenty".
Scalanie robi mergeConsentEntries (lib/utils/consent-entry-merge.ts) po kontekście (rezerwacja / rejestracja / uczestnik) i dokładnym znaczniku accepted_at — recordConsents() stempluje całą partię jedną wartością, więc identyczny znacznik jest precyzyjnym sygnałem „to było jedno zaznaczenie". Jedna akceptacja obejmująca kilka rezerwacji naraz nadal daje osobny wpis dla każdej z nich. W consent_log nic się nie zmienia — dowodem pozostaje osobny wiersz na każdy dokument.
Odczyt na karcie klienta
getConsentHistoryForEmail() zwraca nie tylko wiersze consent_log, ale też rozwiązany kontekst każdej zgody — jednym zapytaniem, bez dociągania per wiersz:
| Kolumna wynikowa | Skąd pochodzi |
|---|---|
subject_player_name | player po subject_player_id, z fallbackiem na dane uczestnika z rejestracji obozowej/kursowej |
context_activity_name | activity_types.name (gra / rezerwacja / trening indywidualny) albo nazwa turnusu |
context_start_time | game.start_time — bezpośrednio, przez booking.game_id lub przez scheduled_game_id |
context_period_start, context_period_end | zakres dat turnusu obozu lub kursu |
context_place | nazwa kortu, a dla turnusów — miasto |
Wszystkie złączenia są LEFT JOIN i warunkowane tenant_id — consent_log nie ma kluczy obcych, więc context_id może wskazywać na rekord już usunięty albo należący do innego tenanta; taki wpis pokazuje się bez kontekstu zamiast znikać lub przeciekać.
Etykiety typów zgód i kanałów mieszkają w constants/consent-labels.ts jako Record<ConsentType, string> i Record<ConsentSource, string>. Totalność rekordu jest tu istotna: dopisanie wartości do ConsentType bez etykiety nie kompiluje się. Wcześniej mapa była lokalną stałą komponentu i zdążyła się rozjechać — account_statute renderował się na karcie klienta jako surowa wartość z bazy.
Wersje dokumentów
Adresy i wersje regulaminów zebrane są w constants/consent-documents.ts i jest to jedyne miejsce w kodzie, w którym wolno zapisać adres dokumentu. Pliki PDF serwowane są z CMS-a i mogą zostać podmienione pod tym samym adresem, dlatego sam URL nie ustala, co zostało zaakceptowane — przy każdej zmianie treści dokumentu należy podbić version.
version ma jeden obowiązujący format: pełna data publikacji pliku, RRRR-MM-DD, brana z daty pliku w CMS-ie (Last-Modified). Pilnuje tego test — wersja z samym miesiącem nie przejdzie. Fallbacki w kodzie (SCHOOL_STATUTE_VERSION, VACATION_PAYMENT_STATUTE_VERSION, VACATION_ENROLLMENT_STATUTE_VERSION) wskazują dziś na version odpowiedniego dokumentu, a nie na sztuczny znacznik v1 — ten trafiał do event_log zajęć i nie mówił nic o zaakceptowanej treści.
Zasada „jedno źródło" nie jest kwestią porządku. Adres wklejony wprost do komponentu rozjeżdża się z tym, który zapisuje recordConsent(), a wtedy rejestr twierdzi, że klient zaakceptował dokument, którego strona nigdy mu nie pokazała — i robi to po cichu. Wcześniej adresy leżały w jedenastu plikach, a stała STATUTE_URL oznaczała regulamin zajęć w trzech z nich i regulamin rezerwacji w czwartym. Test should_be_the_only_place_a_document_address_is_written pilnuje, żeby to nie wróciło.
Dokumenty obozowe i kursowe są wgrywane per sezon i żyją w camp_document / tennis_course_document, więc do rejestru trafia konkretny plik z danego sezonu zamiast globalnej stałej. getConsentDocument() zwraca dla nich null.
Ograniczenie, o którym trzeba wiedzieć. Dokumenty obozowe i kursowe nie są wgrywane do systemu — pracownik wkleja adres URL, a nazwa pliku jest z niego wyprowadzana (camp-documents-section.tsx). Ponowne wgranie nadpisuje wiersz w miejscu.
consent_logzachowuje migawkę adresu i nazwy z chwili akceptacji, ale jeśli ktoś podmieni sam plik PDF pod tym samym adresem w CMS, obie wartości pozostaną identyczne. Odróżnienie takich dwóch treści wymagałoby kolumny wersji w tych tabelach albo archiwizowania kopii pliku przy akceptacji — to nie jest jeszcze zrobione.
Rozdzielenie zgody wizerunkowej (migracja 0209)
user.marketingConsentElectronic pełniło dwie role naraz. Formularze profilu (PrivacySettingsModern.tsx, privacySettingsForm.tsx, term-conditions.tsx) wyświetlały przy nim etykietę „Zgoda wizerunkowa", ikonę aparatu i Zgoda-wizerunkowa.pdf, a CONSENT_EXPR w module mailingu traktował tę samą kolumnę jako zgodę na maile marketingowe. Klucz tłumaczenia nazywał się marketingConsentEmail, ale jego treść brzmiała „Zgoda wizerunkowa" — najprawdopodobniej etykietę i dokument przerobiono w pewnym momencie, nie ruszając zapisu ani zapytania.
Migracja dodaje kolumnę user.imageConsent i przepisuje do niej dotychczasową wartość marketingConsentElectronic — bo to właśnie ją klient widział pod etykietą wizerunkową. Starej kolumny nie czyści: mogą w niej siedzieć starsze, prawdziwe zgody na maile, a migracja ich nie niszczy.
Zawężenie grupy odbiorców zrobione jest w kodzie, nie w migracji, żeby pozostało widoczne i odwracalne w jednym miejscu — CONSENT_EXPR w mail-campaigns.ts.
Ilu klientów traci kwalifikację do kampanii:
SELECT COUNT(*) FROM user
WHERE marketingConsentElectronic = 1
AND (marketingConsent IS NULL OR marketingConsent = 0);
Klucz tłumaczenia został przemianowany na termsConditions.imageRightsConsent, żeby nazwa odpowiadała treści.
Backfill
Migracja przepisuje do rejestru istniejące akceptacje: kolumny *_accepted_at z rezerwacji, obozów i kursów, wpisy consent_accepted z game.event_log oraz aktualny stan zgód marketingowych z profili. Wiersze te mają source = 'backfill'.
Czego backfill nie odzyska: adresu IP, user-agenta ani wersji dokumentu dla akceptacji sprzed migracji, a w przypadku zgód marketingowych — wycofań, które nastąpiły wcześniej (znany jest wyłącznie stan bieżący). Każdy INSERT jest zabezpieczony klauzulą NOT EXISTS, więc ponowne uruchomienie migracji nie zduplikuje wierszy.
Prezentacja w Historii
getActivityLog składa kanał zdarzeń z kilkunastu niezależnych podzapytań, których wyniki scalane są w JavaScripcie. Rejestr zgód dokłada trzy gałęzie — buildBookingConsentSQL, buildCampConsentSQL i buildTennisCourseConsentSQL — zwracające wiersze o typie akcji consent_accepted.
Zgody zajęć celowo nie mają własnej gałęzi: trafiają do kanału przez buildGameConsentAcceptedSQL, które czyta game.event_log przez mirror activity_log. Druga gałąź nad tymi samymi akceptacjami pokazywałaby każdą z nich dwukrotnie.
Rodzaj dokumentu przekazywany jest w update_params_json jako consentType i to on decyduje o treści komunikatu (CONSENT_TYPE_DESCRIPTION_KEY w ActivityLogItem.tsx). Bez tego rozróżnienia każda zgoda byłaby opisana jako regulamin zajęć wakacyjnych, bo taki jest istniejący, jedyny wcześniej potrzebny komunikat.
Testy
__tests__/lib/consent-log.test.ts pokrywa zapis udzielenia i wycofania, stemplowanie czasu po stronie serwera, przechwytywanie IP z obu wariantów nagłówków, działanie poza kontekstem żądania (crony) oraz to, że błąd zapisu nie przerywa operacji nadrzędnej.
__tests__/lib/consent-translation-keys.test.ts pilnuje, by każdy zapisywany rodzaj zgody i każdy kanał miał etykietę w obu językach. Listę kluczy bierze wprost z map w constants/consent-labels.ts, a nie z własnej kopii — kopia rozjechała się z ConsentType i test nie zauważył brakujących etykiet.
__tests__/lib/consent-history-context.test.ts uruchamia zapytanie historii na SQLite w pamięci: sprawdza rozwiązanie uczestnika i zajęć dla kontekstu gry, dojście do zajęć przez rezerwację, fallback na dane uczestnika z rejestracji obozowej oraz to, że kontekst z innego tenanta nie zostaje dołączony.
__tests__/lib/consent-documents.test.ts przeszukuje repozytorium i nie przepuszcza adresu dokumentu wklejonego poza plik stałych.