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 każdym wpisie widać:

  • czy zgoda została udzielona czy wycofana,
  • datę i godzinę,
  • kanał (online, pracownik, link płatniczy, rejestracja),
  • wersję dokumentu i odnośnik do jego treści, jeśli są dostępne,
  • 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ą.

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
Rezerwacja kortu onlinelib/actions/public-booking.tsbooking_statute
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)

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.

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.

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 miał etykietę w obu językach. next-intl rzuca wyjątkiem przy brakującym kluczu, więc nieopisany typ zgody wywróciłby render karty klienta zamiast pokazać surową wartość z bazy.

__tests__/lib/consent-documents.test.ts przeszukuje repozytorium i nie przepuszcza adresu dokumentu wklejonego poza plik stałych.