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 ➔ 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 |
|---|---|---|
| Rezerwacja kortu online | lib/actions/public-booking.ts | booking_statute |
| 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) |
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_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 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.