Przejdź do głównej zawartości

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 ➔ Zgody zapisuje 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 ➔ Logi nie 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.

KolumnaZnaczenie
consent_typerodzaj zgody, np. booking_statute, camp_rodo, marketing_electronic
granted1 = udzielenie, 0 = wycofanie
subject_email, subject_player_idkogo zgoda dotyczy
document_url, document_versionktóry dokument został zaakceptowany
sourceonline, employee, payment_link, registration, api, backfill
actor_email, actor_typekto dokonał akceptacji (customer / employee / system)
ip_address, user_agentodcisk żądania z chwili akceptacji
context_type, context_idpowiązanie z grą, rezerwacją lub rejestracją
accepted_atznacznik 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:

  1. stempluje accepted_at czasem serwera,
  2. wyciąga IP i user-agenta z nagłówków żądania (cf-connecting-ip, z fallbackiem na x-forwarded-for / x-real-ip),
  3. normalizuje adresy e-mail do małych liter,
  4. 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żkaMiejsce w kodzieconsent_type
Opłacenie linku płatniczego rezerwacjiapp/api/public/payment/initializebooking_statute
Płatność za rezerwację w aplikacjilib/actions/users.tsbooking_statute
Obóz — regulamin, RODO, umowalib/actions/camp-payment-link.tscamp_statute, camp_rodo, camp_contract
Kurs tenisowy — regulamin, RODOlib/actions/tennis-course-payment-link.tstennis_course_statute, tennis_course_rodo
Zapis na serię stałąlib/enrollment-consent.tsschool_enrollment
Zapis na zajęcia wakacyjnelib/actions/game.tsvacation_enrollment
Płatność za zajęcia wakacyjnelib/actions/payment.tsvacation_payment
Zgody marketingowe (profil i zmiana hurtowa)lib/actions/users-db.tsmarketing_general, marketing_electronic
Zgoda wizerunkowa (profil)lib/actions/users-db.tsimage_rights
Wypisanie z maililib/actions/mail-campaigns.tsmarketing_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 zawsze player.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 wynikowaSkąd pochodzi
subject_player_nameplayer po subject_player_id, z fallbackiem na dane uczestnika z rejestracji obozowej/kursowej
context_activity_nameactivity_types.name (gra / rezerwacja / trening indywidualny) albo nazwa turnusu
context_start_timegame.start_time — bezpośrednio, przez booking.game_id lub przez scheduled_game_id
context_period_start, context_period_endzakres dat turnusu obozu lub kursu
context_placenazwa 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_log zachowuje 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.